feat: add Excel document generator

This commit is contained in:
binzz
2026-08-12 10:11:48 +09:00
parent b60b933a08
commit ccabd1187b
10 changed files with 1433 additions and 0 deletions

View File

@@ -10,6 +10,9 @@ dependencies {
// Gateway REST API와 관리 화면의 HTTP 요청을 처리합니다.
implementation 'org.springframework.boot:spring-boot-starter-web'
// 등록된 Excel 양식을 보존하면서 Tool 문서를 생성합니다.
implementation 'org.apache.poi:poi-ooxml:5.5.1'
// Tool Registry 및 분산 캐시 연동에 사용합니다.
implementation 'org.springframework.boot:spring-boot-starter-data-redis'

View File

@@ -0,0 +1,11 @@
package io.shinhanlife.dap.mcg.document;
import io.shinhanlife.dap.mcc.dto.ToolMetadata;
public record DocumentGenerationRequest(
ToolMetadata tool,
String version,
boolean includeProgram,
boolean includeProcess,
boolean includeRevision) {
}

View File

@@ -0,0 +1,56 @@
package io.shinhanlife.dap.mcg.document;
import java.nio.charset.StandardCharsets;
import java.util.Map;
import org.springframework.http.CacheControl;
import org.springframework.http.ContentDisposition;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/mcp/api/v1/admin/documents")
public class DocumentGeneratorController {
private static final MediaType XLSX = MediaType.parseMediaType(
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
private final DocumentGeneratorService documentGeneratorService;
public DocumentGeneratorController(DocumentGeneratorService documentGeneratorService) {
this.documentGeneratorService = documentGeneratorService;
}
@PostMapping(value = "/program", produces = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
public ResponseEntity<byte[]> generateProgram(@RequestBody DocumentGenerationRequest request) {
return download(documentGeneratorService.generateProgram(request));
}
@PostMapping(value = "/interface", produces = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
public ResponseEntity<byte[]> generateInterface(@RequestBody DocumentGenerationRequest request) {
return download(documentGeneratorService.generateInterface(request));
}
@ExceptionHandler(IllegalArgumentException.class)
public ResponseEntity<Map<String, String>> invalidRequest(IllegalArgumentException exception) {
return ResponseEntity.badRequest().body(Map.of("error", exception.getMessage()));
}
private ResponseEntity<byte[]> download(GeneratedDocument document) {
ContentDisposition disposition = ContentDisposition.attachment()
.filename(document.fileName(), StandardCharsets.UTF_8)
.build();
return ResponseEntity.ok()
.contentType(XLSX)
.contentLength(document.content().length)
.cacheControl(CacheControl.noStore())
.header(HttpHeaders.CONTENT_DISPOSITION, disposition.toString())
.body(document.content());
}
}

View File

@@ -0,0 +1,534 @@
package io.shinhanlife.dap.mcg.document;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.shinhanlife.dap.mcc.dto.ToolMetadata;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.time.Clock;
import java.time.LocalDate;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.Collection;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import org.apache.poi.ss.usermodel.Cell;
import org.apache.poi.ss.usermodel.CellStyle;
import org.apache.poi.ss.usermodel.Row;
import org.apache.poi.ss.usermodel.Sheet;
import org.apache.poi.ss.usermodel.Workbook;
import org.apache.poi.ss.usermodel.WorkbookFactory;
import org.apache.poi.ss.util.CellRangeAddress;
import org.apache.poi.ss.util.CellReference;
import org.springframework.core.io.ClassPathResource;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
@Service
public class DocumentGeneratorService {
static final String PROGRAM_TEMPLATE = "document-templates/excel/program-definition-template.xlsx";
static final String INTERFACE_TEMPLATE = "document-templates/excel/interface-definition-template.xlsx";
private static final DateTimeFormatter FILE_DATE = DateTimeFormatter.BASIC_ISO_DATE;
private static final DateTimeFormatter DISPLAY_DATE = DateTimeFormatter.ISO_LOCAL_DATE;
private static final Pattern PATTERN_LENGTH = Pattern.compile("\\\\d\\{(\\d+)}");
private static final int INTERFACE_FIRST_ROW = 8;
private static final int INTERFACE_LAST_ROW = 30;
private final ObjectMapper objectMapper;
private final Clock clock;
@Autowired
public DocumentGeneratorService(ObjectMapper objectMapper) {
this(objectMapper, Clock.system(ZoneId.of("Asia/Seoul")));
}
DocumentGeneratorService(ObjectMapper objectMapper, Clock clock) {
this.objectMapper = objectMapper;
this.clock = clock;
}
public GeneratedDocument generateProgram(DocumentGenerationRequest request) {
ToolMetadata tool = validate(request);
if (!request.includeProgram() && !request.includeProcess() && !request.includeRevision()) {
throw new IllegalArgumentException("프로그램 문서에서 한 개 이상의 시트를 선택하세요.");
}
String version = normalizeVersion(request.version());
LocalDate today = LocalDate.now(clock);
try (InputStream input = resource(PROGRAM_TEMPLATE);
Workbook workbook = WorkbookFactory.create(input)) {
Sheet programSheet = requiredSheet(workbook, "프로그램정의서");
Sheet designTemplate = requiredSheet(workbook, "입출력정의");
Sheet revisionSheet = request.includeRevision()
? workbook.cloneSheet(workbook.getSheetIndex(designTemplate))
: null;
if (request.includeProgram()) {
populateProgramSheet(programSheet, tool, version, today);
} else {
workbook.removeSheetAt(workbook.getSheetIndex(programSheet));
}
if (request.includeProcess()) {
workbook.setSheetName(workbook.getSheetIndex(designTemplate), "처리설계");
populateProcessSheet(designTemplate, tool);
} else {
workbook.removeSheetAt(workbook.getSheetIndex(designTemplate));
}
if (revisionSheet != null) {
workbook.setSheetName(workbook.getSheetIndex(revisionSheet), "개정이력");
populateRevisionSheet(revisionSheet, version, today);
}
workbook.setActiveSheet(0);
return output(workbook, programFileName(tool, version, today));
} catch (IOException exception) {
throw new IllegalStateException("프로그램정의서 Excel 생성에 실패했습니다.", exception);
}
}
public GeneratedDocument generateInterface(DocumentGenerationRequest request) {
ToolMetadata tool = validate(request);
String version = normalizeVersion(request.version());
LocalDate today = LocalDate.now(clock);
try (InputStream input = resource(INTERFACE_TEMPLATE);
Workbook workbook = WorkbookFactory.create(input)) {
populateInterfaceSheet(requiredSheet(workbook, "Request In"), tool, false);
populateInterfaceSheet(requiredSheet(workbook, "Response Out"), tool, true);
workbook.setActiveSheet(0);
return output(workbook, interfaceFileName(tool, version, today));
} catch (IOException exception) {
throw new IllegalStateException("인터페이스정의서 Excel 생성에 실패했습니다.", exception);
}
}
private void populateProgramSheet(Sheet sheet, ToolMetadata tool, String version, LocalDate today) {
String label = label(tool);
set(sheet, "B4", documentId(tool));
set(sheet, "E4", version);
set(sheet, "H4", DISPLAY_DATE.format(today));
set(sheet, "B5", label);
set(sheet, "E5", value(tool.getName()));
set(sheet, "H5", "생성 완료");
set(sheet, "B6", value(tool.getCategoryKey()));
set(sheet, "E6", "AX HUB MCP Gateway");
set(sheet, "H6", "자동 생성");
set(sheet, "B9", defaultValue(tool.getDescription(), "설명이 등록되지 않은 Tool입니다."));
set(sheet, "B10", label);
set(sheet, "E10", tool.getOperationType() == null ? "-" : tool.getOperationType().name());
set(sheet, "H10", Boolean.FALSE.equals(tool.getVisible()) ? "비공개" : "공개");
set(sheet, "B11", schemaFields(tool.getParametersSchema()).size()
+ "개 파라미터가 Tool JSON 스키마에서 자동 매핑되었습니다.");
set(sheet, "B14", value(tool.getCategoryKey()));
set(sheet, "E14", value(tool.getPodUrl()));
set(sheet, "H14", value(tool.getIntegrationType()));
set(sheet, "B15", value(tool.getEndpoint()));
set(sheet, "E15", value(tool.getMciServiceId()));
set(sheet, "H15", formatTimeout(tool.getTimeoutMillis()));
set(sheet, "B16", yesNo(tool.getRequiresApproval()));
set(sheet, "E16", yesNo(tool.getReadOnlyHint()));
set(sheet, "H16", yesNo(tool.getIdempotentHint()));
set(sheet, "B21", label);
set(sheet, "C21", "Tool 입력 스키마에 따라 요청 파라미터를 검증합니다.");
set(sheet, "B22", defaultValue(tool.getIntegrationType(), "REST"));
set(sheet, "H22", value(tool.getMciServiceId()));
set(sheet, "A26", "※ ToolMetadata를 기준으로 자동 생성된 문서입니다. 업무 규칙과 승인 정보는 담당자 검토가 필요합니다.");
}
private void populateProcessSheet(Sheet sheet, ToolMetadata tool) {
CellStyle title = style(sheet, "A1");
CellStyle section = style(sheet, "A2");
CellStyle header = style(sheet, "A3");
CellStyle data = style(sheet, "A4");
CellStyle note = style(sheet, "A9");
resetSheet(sheet, 9, 9);
mergeSet(sheet, "A1:I1", "처리설계", title);
mergeSet(sheet, "A2:I2", label(tool) + " · 공통 처리 절차", section);
setStyled(sheet, 2, 0, "단계", header);
setStyled(sheet, 2, 1, "처리 주체", header);
mergeSet(sheet, "C3:F3", "처리 내용", header);
mergeSet(sheet, "G3:H3", "성공 조건", header);
setStyled(sheet, 2, 8, "비고", header);
List<List<String>> steps = List.of(
List.of("1", "Gateway", "호출자 인증과 Tool 실행 권한을 확인합니다.", "권한 검증 성공", "공통 처리"),
List.of("2", label(tool), "Tool 입력 스키마에 따라 요청 파라미터를 검증합니다.", "스키마 검증 성공", "자동 생성"),
List.of("3", defaultValue(tool.getIntegrationType(), "REST"), "등록된 엔드포인트 또는 서비스 ID로 대상 시스템을 호출합니다.", "정상 응답 수신", value(tool.getMciServiceId())),
List.of("4", "Gateway", "응답을 MCP 표준 결과로 변환하고 정책을 검사합니다.", "응답 정책 통과", "응답 정책"),
List.of("5", "Gateway", "감사 로그를 기록하고 호출자에게 결과를 반환합니다.", "응답 전송 완료", "추적 ID 포함")
);
for (int index = 0; index < steps.size(); index++) {
int row = 3 + index;
List<String> step = steps.get(index);
setStyled(sheet, row, 0, step.get(0), data);
setStyled(sheet, row, 1, step.get(1), data);
mergeSet(sheet, "C" + (row + 1) + ":F" + (row + 1), step.get(2), data);
mergeSet(sheet, "G" + (row + 1) + ":H" + (row + 1), step.get(3), data);
setStyled(sheet, row, 8, step.get(4), data);
sheet.getRow(row).setHeightInPoints(38);
}
mergeSet(sheet, "A9:I9", "※ 공통 처리 흐름은 ToolMetadata와 Gateway 정책을 기준으로 자동 작성되었습니다.", note);
}
private void populateRevisionSheet(Sheet sheet, String version, LocalDate today) {
CellStyle title = style(sheet, "A1");
CellStyle section = style(sheet, "A2");
CellStyle header = style(sheet, "A3");
CellStyle data = style(sheet, "A4");
CellStyle note = style(sheet, "A9");
resetSheet(sheet, 9, 9);
mergeSet(sheet, "A1:I1", "개정이력", title);
mergeSet(sheet, "A2:I2", "문서 버전 및 변경 내역", section);
setStyled(sheet, 2, 0, "버전", header);
setStyled(sheet, 2, 1, "작성일", header);
mergeSet(sheet, "C3:D3", "작성자", header);
mergeSet(sheet, "E3:H3", "변경 내용", header);
setStyled(sheet, 2, 8, "비고", header);
setStyled(sheet, 3, 0, version, data);
setStyled(sheet, 3, 1, DISPLAY_DATE.format(today), data);
mergeSet(sheet, "C4:D4", "Document Generator", data);
mergeSet(sheet, "E4:H4", "ToolMetadata 기준 최초 생성", data);
setStyled(sheet, 3, 8, "자동 생성", data);
mergeSet(sheet, "A6:I6", "※ 배포 전 담당자의 최종 검토가 필요합니다.", note);
}
private void populateInterfaceSheet(Sheet sheet, ToolMetadata tool, boolean response) {
String label = label(tool);
String interfaceId = interfaceId(tool);
set(sheet, "A1", label + " 인터페이스 설계서");
set(sheet, "C2", label);
set(sheet, "C3", defaultValue(tool.getDescription(), "설명이 등록되지 않은 Tool입니다."));
set(sheet, "D4", value(tool.getEndpoint()));
set(sheet, "D5", "운영 URL 확인 필요");
set(sheet, "A7", interfaceId);
set(sheet, "B7", response ? "데이터 수신시스템 · 응답 (Response Out)" : "데이터 송신시스템 · 요청 (Request In)");
clearInterfaceRows(sheet);
if (response) {
writeInterfaceField(sheet, INTERFACE_FIRST_ROW, interfaceId, "AX HUB\nMCP Gateway", "Body",
interfaceId + "_O", new SchemaField("resultData", "object", "결과 데이터", "-", true, "", false));
set(sheet, "C33", prettyJson(Map.of("resultData", Map.of("status", "SUCCESS"))));
} else {
List<SchemaField> fields = schemaFields(tool.getParametersSchema());
if (fields.size() > INTERFACE_LAST_ROW - INTERFACE_FIRST_ROW + 1) {
throw new IllegalArgumentException("인터페이스 양식은 최대 23개 요청 필드를 지원합니다.");
}
for (int index = 0; index < fields.size(); index++) {
writeInterfaceField(sheet, INTERFACE_FIRST_ROW + index, interfaceId,
defaultValue(tool.getIntegrationType(), "Tool"), "Body", interfaceId + "_I", fields.get(index));
}
set(sheet, "C33", prettyJson(exampleFromSchema(tool.getParametersSchema())));
}
set(sheet, "A34", "※ ToolMetadata를 기준으로 자동 생성된 검토용 문서입니다.");
}
private void writeInterfaceField(Sheet sheet, int rowIndex, String interfaceId, String system, String level,
String store, SchemaField field) {
if (rowIndex == INTERFACE_FIRST_ROW) {
setStyled(sheet, rowIndex, 0, interfaceId, style(sheet, "A9"));
setStyled(sheet, rowIndex, 1, system, style(sheet, "B9"));
setStyled(sheet, rowIndex, 2, level, style(sheet, "C9"));
}
setStyled(sheet, rowIndex, 3, store, style(sheet, "D9"));
setStyled(sheet, rowIndex, 4, defaultValue(field.description(), field.path()), style(sheet, "E9"));
setStyled(sheet, rowIndex, 5, field.path(), style(sheet, "F9"));
setStyled(sheet, rowIndex, 6, field.type(), style(sheet, "G9"));
setStyled(sheet, rowIndex, 7, field.length(), style(sheet, "H9"));
setStyled(sheet, rowIndex, 8, "", style(sheet, "I9"));
setStyled(sheet, rowIndex, 9, field.coded() ? "Y" : "N", style(sheet, "J9"));
String note = (field.required() ? "필수 · " : "") + "ToolMetadata";
if (!field.example().isBlank()) {
note += " · 예시: " + field.example();
}
setStyled(sheet, rowIndex, 10, note, style(sheet, "K9"));
}
private void clearInterfaceRows(Sheet sheet) {
for (int rowIndex = INTERFACE_FIRST_ROW; rowIndex <= INTERFACE_LAST_ROW; rowIndex++) {
for (int column = 3; column <= 10; column++) {
setStyled(sheet, rowIndex, column, "", style(sheet, "D9"));
}
}
set(sheet, "A9", "");
set(sheet, "B9", "");
set(sheet, "C9", "");
}
private List<SchemaField> schemaFields(Map<String, Object> schema) {
List<SchemaField> fields = new ArrayList<>();
collectSchemaFields(schema, "", Set.of(), fields);
return fields;
}
@SuppressWarnings("unchecked")
private void collectSchemaFields(Map<String, Object> schema, String prefix, Set<String> inheritedRequired,
List<SchemaField> fields) {
if (schema == null) {
return;
}
Object requiredValue = schema.get("required");
Set<String> required = requiredValue instanceof Collection<?> values
? values.stream().map(String::valueOf).collect(java.util.stream.Collectors.toSet())
: inheritedRequired;
Object propertiesValue = schema.get("properties");
if (!(propertiesValue instanceof Map<?, ?> properties)) {
return;
}
for (Map.Entry<?, ?> entry : properties.entrySet()) {
String name = String.valueOf(entry.getKey());
if (!(entry.getValue() instanceof Map<?, ?> rawNode)) {
continue;
}
Map<String, Object> node = (Map<String, Object>) rawNode;
String path = prefix.isBlank() ? name : prefix + "." + name;
String type = String.valueOf(node.getOrDefault("type", "string"));
String description = value(node.get("description"));
String length = schemaLength(node);
String example = schemaExample(node);
boolean coded = node.get("enum") instanceof Collection<?> values && !values.isEmpty();
fields.add(new SchemaField(path, type, description, length, required.contains(name), example, coded));
if ("object".equals(type)) {
collectSchemaFields(node, path, Set.of(), fields);
} else if ("array".equals(type) && node.get("items") instanceof Map<?, ?> items) {
collectSchemaFields((Map<String, Object>) items, path + "[]", Set.of(), fields);
}
}
}
private String schemaLength(Map<String, Object> node) {
Object length = node.get("maxLength");
if (length == null) {
length = node.get("length");
}
if (length != null) {
return String.valueOf(length);
}
Object pattern = node.get("pattern");
if (pattern != null) {
Matcher matcher = PATTERN_LENGTH.matcher(String.valueOf(pattern));
if (matcher.find()) {
return matcher.group(1);
}
}
return "-";
}
private String schemaExample(Map<String, Object> node) {
Object example = node.get("example");
if (example == null && node.get("examples") instanceof List<?> examples && !examples.isEmpty()) {
example = examples.get(0);
}
if (example == null) {
example = node.get("default");
}
return value(example);
}
@SuppressWarnings("unchecked")
private Object exampleFromSchema(Map<String, Object> schema) {
if (schema == null) {
return Map.of();
}
Object type = schema.get("type");
if ("object".equals(type) || schema.get("properties") instanceof Map<?, ?>) {
Map<String, Object> result = new LinkedHashMap<>();
Object propertiesValue = schema.get("properties");
if (propertiesValue instanceof Map<?, ?> properties) {
for (Map.Entry<?, ?> entry : properties.entrySet()) {
if (entry.getValue() instanceof Map<?, ?> node) {
result.put(String.valueOf(entry.getKey()), exampleFromSchema((Map<String, Object>) node));
}
}
}
return result;
}
Object example = schema.get("example");
if (example == null && schema.get("examples") instanceof List<?> examples && !examples.isEmpty()) {
example = examples.get(0);
}
if (example == null) {
example = schema.get("default");
}
if (example != null) {
return example;
}
return switch (String.valueOf(type)) {
case "integer", "number" -> 0;
case "boolean" -> false;
case "array" -> schema.get("items") instanceof Map<?, ?> items
? List.of(exampleFromSchema((Map<String, Object>) items)) : List.of();
default -> "<value>";
};
}
private String prettyJson(Object value) {
try {
return objectMapper.writerWithDefaultPrettyPrinter().writeValueAsString(value);
} catch (JsonProcessingException exception) {
throw new IllegalStateException("JSON 예시 생성에 실패했습니다.", exception);
}
}
private GeneratedDocument output(Workbook workbook, String fileName) throws IOException {
try (ByteArrayOutputStream output = new ByteArrayOutputStream()) {
workbook.write(output);
return new GeneratedDocument(fileName, output.toByteArray());
}
}
private InputStream resource(String path) throws IOException {
return new ClassPathResource(path).getInputStream();
}
private ToolMetadata validate(DocumentGenerationRequest request) {
if (request == null || request.tool() == null) {
throw new IllegalArgumentException("ToolMetadata가 필요합니다.");
}
ToolMetadata tool = request.tool();
if (isBlank(tool.getUid()) && isBlank(tool.getName())) {
throw new IllegalArgumentException("Tool UID 또는 Tool 이름이 필요합니다.");
}
return tool;
}
private String programFileName(ToolMetadata tool, String version, LocalDate today) {
return safeFilename(label(tool)) + "_프로그램정의서_v" + version + "_" + FILE_DATE.format(today) + ".xlsx";
}
private String interfaceFileName(ToolMetadata tool, String version, LocalDate today) {
return safeFilename(label(tool)) + "_인터페이스정의서_v" + version + "_" + FILE_DATE.format(today) + ".xlsx";
}
private String normalizeVersion(String version) {
String normalized = isBlank(version) ? "1.0" : version.trim().replaceFirst("^[vV]", "");
if (!normalized.matches("[0-9A-Za-z._-]+")) {
throw new IllegalArgumentException("버전은 영문, 숫자, 점, 밑줄, 하이픈만 사용할 수 있습니다.");
}
return normalized;
}
private String safeFilename(String value) {
String result = defaultValue(value, "tool").replaceAll("[\\\\/:*?\"<>|]", "_").trim();
return result.isEmpty() ? "tool" : result;
}
private String label(ToolMetadata tool) {
return defaultValue(tool.getDisplayName(), defaultValue(tool.getName(), tool.getUid()));
}
private String documentId(ToolMetadata tool) {
String category = defaultValue(tool.getCategoryKey(), "ETC").toUpperCase(Locale.ROOT);
String uid = defaultValue(tool.getUid(), tool.getName()).replaceAll("[^0-9A-Za-z]", "");
uid = uid.length() > 7 ? uid.substring(0, 7) : uid;
return "AXHUB-FS-" + category + "-" + uid.toUpperCase(Locale.ROOT);
}
private String interfaceId(ToolMetadata tool) {
int hash = Objects.hash(tool.getUid(), tool.getName());
return "AXHUB" + String.format(Locale.ROOT, "%05d", Math.floorMod(hash, 100_000));
}
private String formatTimeout(Long timeout) {
return timeout == null ? "-" : String.format(Locale.ROOT, "%,d ms", timeout);
}
private String yesNo(Boolean value) {
return Boolean.TRUE.equals(value) ? "Y" : "N";
}
private Sheet requiredSheet(Workbook workbook, String name) {
Sheet sheet = workbook.getSheet(name);
if (sheet == null) {
throw new IllegalStateException("Excel 템플릿에 '" + name + "' 시트가 없습니다.");
}
return sheet;
}
private void resetSheet(Sheet sheet, int rows, int columns) {
for (int index = sheet.getNumMergedRegions() - 1; index >= 0; index--) {
sheet.removeMergedRegion(index);
}
for (int rowIndex = 0; rowIndex < rows; rowIndex++) {
Row row = row(sheet, rowIndex);
for (int column = 0; column < columns; column++) {
cell(row, column).setBlank();
}
}
}
private void mergeSet(Sheet sheet, String range, String value, CellStyle style) {
CellRangeAddress address = CellRangeAddress.valueOf(range);
sheet.addMergedRegion(address);
for (int rowIndex = address.getFirstRow(); rowIndex <= address.getLastRow(); rowIndex++) {
for (int column = address.getFirstColumn(); column <= address.getLastColumn(); column++) {
Cell target = cell(row(sheet, rowIndex), column);
target.setCellStyle(style);
if (rowIndex == address.getFirstRow() && column == address.getFirstColumn()) {
target.setCellValue(value);
}
}
}
}
private void set(Sheet sheet, String reference, String value) {
CellReference cellReference = new CellReference(reference);
Cell target = cell(row(sheet, cellReference.getRow()), cellReference.getCol());
target.setBlank();
target.setCellValue(defaultValue(value, ""));
}
private void setStyled(Sheet sheet, int rowIndex, int column, String value, CellStyle style) {
Cell target = cell(row(sheet, rowIndex), column);
target.setBlank();
target.setCellStyle(style);
target.setCellValue(defaultValue(value, ""));
}
private CellStyle style(Sheet sheet, String reference) {
CellReference cellReference = new CellReference(reference);
return cell(row(sheet, cellReference.getRow()), cellReference.getCol()).getCellStyle();
}
private Row row(Sheet sheet, int rowIndex) {
Row row = sheet.getRow(rowIndex);
return row == null ? sheet.createRow(rowIndex) : row;
}
private Cell cell(Row row, int column) {
Cell cell = row.getCell(column);
return cell == null ? row.createCell(column) : cell;
}
private String value(Object value) {
return value == null ? "" : String.valueOf(value);
}
private String defaultValue(String value, String fallback) {
return isBlank(value) ? (fallback == null ? "" : fallback) : value;
}
private boolean isBlank(String value) {
return value == null || value.isBlank();
}
private record SchemaField(String path, String type, String description, String length,
boolean required, String example, boolean coded) {
}
}

View File

@@ -0,0 +1,4 @@
package io.shinhanlife.dap.mcg.document;
public record GeneratedDocument(String fileName, byte[] content) {
}

View File

@@ -12,6 +12,383 @@
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@fontsource/geist-mono@5.0.1/400.css">
<!-- Bootstrap CSS -->
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<style id="document-generator-styles">
.document-generator {
--docgen-panel: #111113;
--docgen-panel-soft: #18181b;
--docgen-line: #2a2a2f;
--docgen-line-strong: #3f3f46;
--docgen-text: #f4f4f5;
--docgen-muted: #92929d;
--docgen-blue: #3b82f6;
--docgen-blue-soft: rgba(59, 130, 246, 0.12);
--docgen-green: #6ee7a0;
--docgen-amber: #facc50;
--docgen-danger: #fb7185;
color: var(--docgen-text);
}
.document-generator *,
.document-generator *::before,
.document-generator *::after {
box-sizing: border-box;
}
.document-generator .docgen-workspace {
display: grid;
grid-template-columns: minmax(340px, 0.9fr) minmax(460px, 1.35fr);
margin: -2rem -1.5rem;
min-height: 680px;
}
.document-generator .docgen-config,
.document-generator .docgen-preview {
padding: 28px;
}
.document-generator .docgen-config {
border-right: 1px solid var(--docgen-line);
}
.document-generator .docgen-section-title {
margin: 0 0 6px;
color: var(--docgen-text);
font-size: 16px;
font-weight: 650;
}
.document-generator .docgen-section-copy {
margin: 0 0 24px;
color: var(--docgen-muted);
font-size: 13px;
line-height: 1.6;
}
.document-generator .docgen-field {
margin-bottom: 25px;
}
.document-generator .docgen-field-label {
display: block;
margin-bottom: 9px;
color: var(--docgen-text);
font-size: 13px;
font-weight: 650;
}
.document-generator select,
.document-generator input[type="text"] {
width: 100%;
padding: 13px 14px;
border: 1px solid var(--docgen-line-strong);
border-radius: 7px;
outline: 0;
background: var(--docgen-panel-soft);
color: var(--docgen-text);
font: inherit;
}
.document-generator select:focus,
.document-generator input[type="text"]:focus {
border-color: var(--docgen-blue);
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.12);
}
.document-generator .docgen-hint {
margin-top: 9px;
color: var(--docgen-muted);
font-size: 12px;
}
.document-generator .docgen-two-col {
display: grid;
grid-template-columns: 1fr 150px;
gap: 18px;
}
.document-generator .docgen-check-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
}
.document-generator .docgen-check-card {
display: flex;
align-items: center;
min-height: 58px;
gap: 11px;
padding: 12px 14px;
border: 1px solid var(--docgen-line-strong);
border-radius: 7px;
background: var(--docgen-panel-soft);
color: #d4d4d8;
cursor: pointer;
font-size: 13px;
}
.document-generator .docgen-check-card:hover {
border-color: #52525b;
}
.document-generator .docgen-check-card:has(input:checked) {
border-color: rgba(59, 130, 246, 0.72);
background: var(--docgen-blue-soft);
color: #fff;
}
.document-generator input[type="checkbox"] {
width: 17px;
height: 17px;
margin: 0;
accent-color: var(--docgen-blue);
}
.document-generator .docgen-mini-badge {
margin-left: auto;
padding: 4px 7px;
border: 1px solid var(--docgen-line-strong);
border-radius: 5px;
background: #27272a;
color: #a1a1aa;
font: 600 10px 'Geist Mono', monospace;
}
.document-generator .docgen-action-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 18px;
margin-top: 30px;
padding-top: 24px;
border-top: 1px solid var(--docgen-line);
}
.document-generator .docgen-source-label {
color: var(--docgen-muted);
font-size: 12px;
}
.document-generator .docgen-primary {
padding: 13px 22px;
border: 0;
border-radius: 7px;
background: var(--docgen-blue);
color: #fff;
cursor: pointer;
font-weight: 700;
white-space: nowrap;
}
.document-generator .docgen-primary:hover {
background: #2563eb;
}
.document-generator .docgen-primary:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.document-generator .docgen-meta-card {
min-height: 155px;
padding: 20px;
border: 1px solid var(--docgen-line-strong);
border-radius: 8px;
background: var(--docgen-panel-soft);
}
.document-generator .docgen-meta-title {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 20px;
}
.document-generator .docgen-meta-title h2 {
margin: 0 0 8px;
color: var(--docgen-text);
font-size: 17px;
}
.document-generator .docgen-meta-title p {
margin: 0;
color: var(--docgen-muted);
font-size: 13px;
line-height: 1.5;
}
.document-generator .docgen-status {
padding: 5px 8px;
border: 1px solid rgba(110, 231, 160, 0.28);
border-radius: 5px;
background: rgba(110, 231, 160, 0.07);
color: var(--docgen-green);
font: 700 10px 'Geist Mono', monospace;
white-space: nowrap;
}
.document-generator .docgen-meta-badges {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 24px;
}
.document-generator .docgen-tag {
padding: 5px 8px;
border: 1px solid #3f3f46;
border-radius: 5px;
background: #27272a;
color: #a1a1aa;
font: 600 10px 'Geist Mono', monospace;
}
.document-generator .docgen-tag.docgen-blue {
border-color: #2563eb;
background: rgba(37, 99, 235, 0.08);
color: #60a5fa;
}
.document-generator .docgen-list {
display: grid;
gap: 10px;
margin-top: 18px;
}
.document-generator .docgen-row {
display: grid;
grid-template-columns: 42px 1fr auto;
align-items: center;
min-height: 68px;
gap: 14px;
padding: 13px 16px;
border: 1px solid var(--docgen-line);
border-radius: 7px;
background: #151518;
opacity: 0.42;
transition: opacity 0.15s, border-color 0.15s;
}
.document-generator .docgen-row.docgen-enabled {
border-color: #303037;
opacity: 1;
}
.document-generator .docgen-number {
display: grid;
place-items: center;
width: 34px;
height: 34px;
border-radius: 6px;
background: #27272a;
color: #a1a1aa;
font: 600 11px 'Geist Mono', monospace;
}
.document-generator .docgen-name {
margin-bottom: 5px;
color: var(--docgen-text);
font-size: 13px;
font-weight: 700;
}
.document-generator .docgen-description {
color: var(--docgen-muted);
font-size: 11px;
}
.document-generator .docgen-mode {
padding: 5px 8px;
border: 1px solid #3f3f46;
border-radius: 5px;
background: #27272a;
color: var(--docgen-muted);
font: 700 10px 'Geist Mono', monospace;
}
.document-generator .docgen-mode.docgen-auto { color: var(--docgen-green); }
.document-generator .docgen-mode.docgen-mixed { color: var(--docgen-amber); }
.document-generator .docgen-mode.docgen-separate {
border-color: #2563eb;
background: rgba(37, 99, 235, 0.08);
color: #60a5fa;
}
.document-generator .docgen-legend {
margin: 16px 0 10px;
color: var(--docgen-muted);
font-size: 11px;
line-height: 1.6;
}
.document-generator .docgen-files {
display: grid;
gap: 10px;
}
.document-generator .docgen-file {
min-height: 72px;
padding: 14px 16px;
border: 1px dashed #3f3f46;
border-radius: 7px;
}
.document-generator .docgen-file.docgen-disabled {
display: none;
}
.document-generator .docgen-file-kind {
margin-bottom: 9px;
color: var(--docgen-green);
font: 700 10px 'Geist Mono', monospace;
}
.document-generator .docgen-file-name {
color: #a1a1aa;
font: 500 11px 'Geist Mono', monospace;
word-break: break-all;
}
.document-generator .docgen-loading { color: var(--docgen-muted); }
.document-generator .docgen-error { color: var(--docgen-danger); }
.docgen-toast {
position: fixed;
right: 24px;
bottom: 24px;
z-index: 1080;
padding: 13px 16px;
border: 1px solid #3f3f46;
border-radius: 8px;
background: #18181b;
box-shadow: 0 18px 50px rgba(0, 0, 0, 0.45);
color: #e4e4e7;
font-size: 12px;
opacity: 0;
pointer-events: none;
transform: translateY(10px);
transition: 0.2s;
}
.docgen-toast.docgen-show {
opacity: 1;
transform: translateY(0);
}
@media (max-width: 900px) {
.document-generator .docgen-workspace {
grid-template-columns: 1fr;
}
.document-generator .docgen-config {
border-right: 0;
border-bottom: 1px solid var(--docgen-line);
}
.document-generator .docgen-two-col {
grid-template-columns: 1fr;
}
}
</style>
<style>
:root {
--bg-color: #09090b;
@@ -405,6 +782,9 @@
<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>
</li>
<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>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="list-tab" data-bs-toggle="tab" data-bs-target="#list" type="button" role="tab" onclick="loadToolList()">Registry</button>
</li>
@@ -595,6 +975,78 @@
</form>
</div>
<!-- Document Generator -->
<div class="tab-pane fade document-generator" id="document" role="tabpanel" aria-labelledby="document-tab">
<div class="docgen-workspace">
<section class="docgen-config">
<h2 class="docgen-section-title">Document Generator</h2>
<p class="docgen-section-copy">Tool Metadata를 기준으로 개발 산출물 Excel을 생성합니다. 선택된 문서만 파일과 시트에 포함됩니다.</p>
<div class="docgen-field">
<label class="docgen-field-label" for="docgenToolSelect">Tool</label>
<select id="docgenToolSelect" disabled><option>Tool Metadata를 불러오는 중...</option></select>
<div id="docgenToolHint" class="docgen-hint">Gateway의 ToolMetadata 목록을 조회하고 있습니다.</div>
</div>
<div class="docgen-two-col docgen-field">
<div>
<label class="docgen-field-label" for="docgenDocumentType">Document Type</label>
<select id="docgenDocumentType"><option>Program Definition / Design</option></select>
</div>
<div>
<label class="docgen-field-label" for="docgenVersion">Version</label>
<input id="docgenVersion" type="text" value="1.0" autocomplete="off">
</div>
</div>
<div class="docgen-field">
<label class="docgen-field-label">Included Sheets</label>
<div class="docgen-check-grid">
<label class="docgen-check-card"><input id="docgenProgramCheck" type="checkbox" checked><span>프로그램정의서</span></label>
<label class="docgen-check-card"><input id="docgenProcessCheck" type="checkbox" checked><span>처리설계</span></label>
<label class="docgen-check-card"><input id="docgenInterfaceCheck" type="checkbox" checked><span>인터페이스</span><span class="docgen-mini-badge">별도 파일</span></label>
<label class="docgen-check-card"><input id="docgenRevisionCheck" type="checkbox" checked><span>개정이력</span></label>
</div>
</div>
<div class="docgen-action-row">
<span class="docgen-source-label">Tool metadata · Generated XLSX</span>
<button id="docgenGenerateBtn" class="docgen-primary" type="button" disabled>Generate EXCEL</button>
</div>
</section>
<section class="docgen-preview">
<div class="docgen-meta-card">
<div class="docgen-meta-title">
<div>
<h2 id="docgenToolTitle" class="docgen-loading">Tool Metadata loading...</h2>
<p id="docgenToolDescription">등록된 Tool 목록을 가져오는 중입니다.</p>
</div>
<span id="docgenMetadataStatus" class="docgen-status">SYNC</span>
</div>
<div class="docgen-meta-badges">
<span id="docgenCategoryTag" class="docgen-tag">Category · -</span>
<span id="docgenInterfaceTag" class="docgen-tag">Interface · -</span>
<span class="docgen-tag docgen-blue">Tool Metadata</span>
</div>
</div>
<div class="docgen-list">
<div id="docgenProgramRow" class="docgen-row docgen-enabled"><span class="docgen-number">01</span><div><div class="docgen-name">프로그램정의서</div><div class="docgen-description">Basic information · Runtime · Process summary</div></div><span class="docgen-mode docgen-auto">AUTO</span></div>
<div id="docgenProcessRow" class="docgen-row docgen-enabled"><span class="docgen-number">02</span><div><div class="docgen-name">처리설계</div><div class="docgen-description">Tool metadata + common design</div></div><span class="docgen-mode docgen-mixed">MIXED</span></div>
<div id="docgenInterfaceRow" class="docgen-row docgen-enabled"><span class="docgen-number">03</span><div><div class="docgen-name">인터페이스</div><div class="docgen-description">Request In · Response Out · 2 sheets</div></div><span class="docgen-mode docgen-separate">2 SHEETS · SEPARATE</span></div>
<div id="docgenRevisionRow" class="docgen-row docgen-enabled"><span class="docgen-number">04</span><div><div class="docgen-name">개정이력</div><div class="docgen-description">Version · Date · Change information</div></div><span class="docgen-mode">MANUAL</span></div>
</div>
<p class="docgen-legend">AUTO: Tool metadata · MIXED: Metadata + common design · MANUAL: Review required<br>Output Files · 인터페이스 정의서는 별도 Excel 파일로 생성됩니다.</p>
<div class="docgen-files">
<div id="docgenProgramFile" class="docgen-file"><div class="docgen-file-kind">PROGRAM SPEC</div><div id="docgenProgramFileName" class="docgen-file-name">tool_프로그램정의서_v1.0_YYYYMMDD.xlsx</div></div>
<div id="docgenInterfaceFile" class="docgen-file"><div class="docgen-file-kind">INTERFACE SPEC · REQUEST IN / RESPONSE OUT</div><div id="docgenInterfaceFileName" class="docgen-file-name">tool_인터페이스정의서_v1.0_YYYYMMDD.xlsx</div></div>
</div>
</section>
</div>
</div>
<!-- Tool List Form -->
<div class="tab-pane fade" id="list" role="tabpanel">
<div class="d-flex justify-content-between align-items-center mb-4">
@@ -736,6 +1188,192 @@
<!-- Bootstrap JS -->
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
<script id="document-generator-script">
(() => {
'use strict';
const state = { tools: [], selected: null };
let checks = null;
let initialized = false;
const dg = (id) => document.getElementById(id);
function initialize() {
if (!initialized) {
checks = {
program: dg('docgenProgramCheck'),
process: dg('docgenProcessCheck'),
interface: dg('docgenInterfaceCheck'),
revision: dg('docgenRevisionCheck')
};
Object.entries(checks).forEach(([name, checkbox]) => checkbox.addEventListener('change', () => {
dg(`docgen${capitalize(name)}Row`).classList.toggle('docgen-enabled', checkbox.checked);
updateOutputPreview();
}));
dg('docgenToolSelect').addEventListener('change', selectTool);
dg('docgenVersion').addEventListener('input', updateOutputPreview);
dg('docgenGenerateBtn').addEventListener('click', generateFiles);
initialized = true;
}
loadTools();
}
async function loadTools() {
const select = dg('docgenToolSelect');
const button = dg('docgenGenerateBtn');
select.disabled = true;
button.disabled = true;
dg('docgenToolHint').textContent = 'Gateway의 ToolMetadata 목록을 조회하고 있습니다.';
try {
const response = await fetch('/mcp/api/v1/tools/list', { cache: 'no-store' });
if (!response.ok) throw new Error(`Gateway HTTP ${response.status}`);
const payload = await response.json();
const tools = payload?.result?.tools;
if (!Array.isArray(tools)) throw new Error('ToolMetadata 응답 형식이 올바르지 않습니다.');
state.tools = tools.slice().sort((a, b) =>
(a.categoryKey || '').localeCompare(b.categoryKey || '') ||
(a.name || '').localeCompare(b.name || '')
);
select.innerHTML = state.tools.map((tool, index) =>
`<option value="${index}">[${escapeHtml(tool.categoryKey || 'etc')}] ${escapeHtml(tool.name || tool.displayName || tool.uid)}</option>`
).join('');
select.disabled = state.tools.length === 0;
dg('docgenToolHint').textContent = `ToolMetadata에 등록된 ${state.tools.length}개 Tool을 모두 표시합니다.`;
if (state.tools.length) {
select.value = '0';
selectTool();
} else {
showMetadataError('등록된 Tool이 없습니다.');
}
} catch (error) {
state.tools = [];
state.selected = null;
showMetadataError(error.message);
select.innerHTML = '<option>Tool Metadata 조회 실패</option>';
dg('docgenToolHint').innerHTML = `<span class="docgen-error">${escapeHtml(error.message)}</span> · Gateway 실행 상태를 확인하세요.`;
}
}
function selectTool() {
state.selected = state.tools[Number(dg('docgenToolSelect').value)] || null;
const tool = state.selected;
if (!tool) return;
dg('docgenVersion').value = normalizeVersion(tool.semver || dg('docgenVersion').value || '1.0');
dg('docgenToolTitle').classList.remove('docgen-loading', 'docgen-error');
dg('docgenToolTitle').textContent = tool.displayName || tool.name;
dg('docgenToolDescription').textContent = tool.description || '설명이 등록되지 않은 Tool입니다.';
dg('docgenCategoryTag').textContent = `Category · ${tool.categoryKey || '-'}`;
dg('docgenInterfaceTag').textContent = `Interface · ${tool.integrationType || '-'}`;
dg('docgenMetadataStatus').textContent = tool.enabled === false ? 'DISABLED' : 'READY';
updateOutputPreview();
}
function showMetadataError(message) {
dg('docgenToolTitle').className = 'docgen-error';
dg('docgenToolTitle').textContent = 'Tool Metadata unavailable';
dg('docgenToolDescription').textContent = message;
dg('docgenMetadataStatus').textContent = 'ERROR';
dg('docgenGenerateBtn').disabled = true;
}
function updateOutputPreview() {
const toolName = safeFilename(state.selected?.displayName || state.selected?.name || 'tool');
const version = safeVersion(dg('docgenVersion').value);
const date = yyyymmdd();
const hasProgram = checks.program.checked || checks.process.checked || checks.revision.checked;
dg('docgenProgramFile').classList.toggle('docgen-disabled', !hasProgram);
dg('docgenInterfaceFile').classList.toggle('docgen-disabled', !checks.interface.checked);
dg('docgenProgramFileName').textContent = `${toolName}_프로그램정의서_v${version}_${date}.xlsx`;
dg('docgenInterfaceFileName').textContent = `${toolName}_인터페이스정의서_v${version}_${date}.xlsx`;
dg('docgenGenerateBtn').disabled = !state.selected || (!hasProgram && !checks.interface.checked);
}
async function generateFiles() {
const tool = state.selected;
if (!tool) return notify('생성할 Tool을 선택하세요.');
const button = dg('docgenGenerateBtn');
const version = safeVersion(dg('docgenVersion').value);
const date = yyyymmdd();
const toolName = safeFilename(tool.displayName || tool.name || 'tool');
const hasProgram = checks.program.checked || checks.process.checked || checks.revision.checked;
const request = {
tool,
version,
includeProgram: checks.program.checked,
includeProcess: checks.process.checked,
includeRevision: checks.revision.checked
};
button.disabled = true;
button.textContent = 'Generating...';
try {
if (hasProgram) {
await requestDownload('/mcp/api/v1/admin/documents/program', request,
`${toolName}_프로그램정의서_v${version}_${date}.xlsx`);
}
if (checks.interface.checked) {
await requestDownload('/mcp/api/v1/admin/documents/interface', request,
`${toolName}_인터페이스정의서_v${version}_${date}.xlsx`);
}
notify('선택한 Excel 문서 다운로드를 완료했습니다.');
} catch (error) {
notify(error.message);
} finally {
button.textContent = 'Generate EXCEL';
updateOutputPreview();
}
}
async function requestDownload(url, payload, filename) {
const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (!response.ok) {
let message = `문서 생성에 실패했습니다. (HTTP ${response.status})`;
try {
const error = await response.json();
if (error?.error) message = error.error;
} catch (_) {
// JSON 오류 응답이 아닌 경우 기본 메시지를 사용합니다.
}
throw new Error(message);
}
const blob = await response.blob();
const href = URL.createObjectURL(blob);
const anchor = document.createElement('a');
anchor.href = href;
anchor.download = filename;
document.body.appendChild(anchor);
anchor.click();
anchor.remove();
URL.revokeObjectURL(href);
}
function safeFilename(value) { return String(value).replace(/[\\/:*?"<>|]/g, '_').trim() || 'tool'; }
function safeVersion(value) { return String(value || '1.0').replace(/^v/i, '').replace(/[^0-9A-Za-z._-]/g, '_') || '1.0'; }
function normalizeVersion(value) { return String(value).replace(/^v/i, '') || '1.0'; }
function yyyymmdd() {
const date = new Date();
return `${date.getFullYear()}${String(date.getMonth() + 1).padStart(2, '0')}${String(date.getDate()).padStart(2, '0')}`;
}
function capitalize(value) { return value.charAt(0).toUpperCase() + value.slice(1); }
function escapeHtml(value) {
return String(value).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;').replace(/'/g, '&apos;');
}
function notify(message) {
const toast = dg('docgenToast');
toast.textContent = message;
toast.classList.add('docgen-show');
window.setTimeout(() => toast.classList.remove('docgen-show'), 2800);
}
window.loadDocumentGenerator = initialize;
})();
</script>
<div id="docgenToast" class="docgen-toast"></div>
<script>
function handleFormSubmit(formId, apiUrl) {
document.getElementById(formId).addEventListener('submit', function(e) {

View File

@@ -0,0 +1,57 @@
package io.shinhanlife.dap.mcg.document;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.shinhanlife.dap.mcc.dto.ToolMetadata;
import java.nio.charset.StandardCharsets;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.http.HttpHeaders;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
@ExtendWith(MockitoExtension.class)
class DocumentGeneratorControllerTest {
@Mock
private DocumentGeneratorService service;
private MockMvc mockMvc;
private ObjectMapper objectMapper;
@BeforeEach
void setUp() {
objectMapper = new ObjectMapper();
mockMvc = MockMvcBuilders.standaloneSetup(new DocumentGeneratorController(service)).build();
}
@Test
void downloadsGeneratedProgramWorkbook() throws Exception {
byte[] workbook = "xlsx-content".getBytes(StandardCharsets.UTF_8);
when(service.generateProgram(any())).thenReturn(new GeneratedDocument(
"고객정보 조회 Tool_프로그램정의서_v1.0_20260811.xlsx", workbook));
DocumentGenerationRequest request = new DocumentGenerationRequest(
ToolMetadata.builder().uid("tool-uid").name("oth.cmm.customer.detail").build(),
"1.0", true, true, true);
mockMvc.perform(post("/mcp/api/v1/admin/documents/program")
.contentType("application/json")
.content(objectMapper.writeValueAsBytes(request)))
.andExpect(status().isOk())
.andExpect(content().contentType(
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"))
.andExpect(header().string(HttpHeaders.CACHE_CONTROL, "no-store"))
.andExpect(header().string(HttpHeaders.CONTENT_DISPOSITION,
org.hamcrest.Matchers.containsString("attachment")))
.andExpect(content().bytes(workbook));
}
}

View File

@@ -0,0 +1,130 @@
package io.shinhanlife.dap.mcg.document;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.shinhanlife.dap.lib.dto.OperationType;
import io.shinhanlife.dap.mcc.dto.ToolMetadata;
import java.io.ByteArrayInputStream;
import java.time.Clock;
import java.time.Instant;
import java.time.ZoneId;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import org.apache.poi.ss.usermodel.Workbook;
import org.apache.poi.ss.usermodel.WorkbookFactory;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
class DocumentGeneratorServiceTest {
private DocumentGeneratorService service;
private ToolMetadata tool;
@BeforeEach
void setUp() {
Clock clock = Clock.fixed(Instant.parse("2026-08-11T03:00:00Z"), ZoneId.of("Asia/Seoul"));
service = new DocumentGeneratorService(new ObjectMapper(), clock);
Map<String, Object> properties = new LinkedHashMap<>();
properties.put("customerId", Map.of(
"type", "string",
"description", "고객 ID",
"pattern", "\\d{8}",
"examples", List.of("12345678")));
properties.put("pageSize", Map.of(
"type", "integer",
"description", "페이지 크기",
"default", 20));
tool = ToolMetadata.builder()
.uid("ebf042d9-2203-3992-9237-634a58515223")
.semver("1.0.0")
.displayName("테스트 고객조회 Tool")
.name("oth.cmm.customer.detail")
.description("테스트 고객의 상세정보를 조회합니다.")
.categoryKey("cmm")
.endpoint("http://was-oth:8084/mcp/oth.cmm.customer.detail")
.podUrl("http://was-oth:8084")
.integrationType("REST")
.mciServiceId("CUST_001")
.operationType(OperationType.READ)
.timeoutMillis(5000L)
.visible(true)
.parametersSchema(Map.of(
"type", "object",
"properties", properties,
"required", List.of("customerId")))
.build();
}
@Test
void createsSelectedProgramSheetsFromTemplate() throws Exception {
GeneratedDocument document = service.generateProgram(
new DocumentGenerationRequest(tool, "1.0", true, true, true));
assertThat(document.fileName())
.isEqualTo("테스트 고객조회 Tool_프로그램정의서_v1.0_20260811.xlsx");
try (Workbook workbook = WorkbookFactory.create(new ByteArrayInputStream(document.content()))) {
assertThat(workbook.getNumberOfSheets()).isEqualTo(3);
assertThat(workbook.getSheetName(0)).isEqualTo("프로그램정의서");
assertThat(workbook.getSheetName(1)).isEqualTo("처리설계");
assertThat(workbook.getSheetName(2)).isEqualTo("개정이력");
assertThat(workbook.getSheet("프로그램정의서").getRow(4).getCell(1).getStringCellValue())
.isEqualTo("테스트 고객조회 Tool");
assertThat(workbook.getSheet("처리설계").getRow(0).getCell(0).getStringCellValue())
.isEqualTo("처리설계");
assertThat(workbook.getSheet("개정이력").getRow(3).getCell(0).getStringCellValue())
.isEqualTo("1.0");
assertThat(workbook.getNumCellStyles()).isGreaterThan(8);
}
}
@Test
void removesUncheckedProgramSheets() throws Exception {
GeneratedDocument document = service.generateProgram(
new DocumentGenerationRequest(tool, "v2.0", false, false, true));
try (Workbook workbook = WorkbookFactory.create(new ByteArrayInputStream(document.content()))) {
assertThat(workbook.getNumberOfSheets()).isEqualTo(1);
assertThat(workbook.getSheetName(0)).isEqualTo("개정이력");
assertThat(workbook.getSheetAt(0).getRow(3).getCell(0).getStringCellValue()).isEqualTo("2.0");
}
}
@Test
void createsRequestAndResponseInterfaceSheetsFromTemplate() throws Exception {
GeneratedDocument document = service.generateInterface(
new DocumentGenerationRequest(tool, "1.0", false, false, false));
assertThat(document.fileName())
.isEqualTo("테스트 고객조회 Tool_인터페이스정의서_v1.0_20260811.xlsx");
try (Workbook workbook = WorkbookFactory.create(new ByteArrayInputStream(document.content()))) {
assertThat(workbook.getNumberOfSheets()).isEqualTo(2);
assertThat(workbook.getSheetName(0)).isEqualTo("Request In");
assertThat(workbook.getSheetName(1)).isEqualTo("Response Out");
assertThat(workbook.getSheet("Request In").getRow(1).getCell(2).getStringCellValue())
.isEqualTo("테스트 고객조회 Tool");
assertThat(workbook.getSheet("Request In").getRow(3).getCell(3).getStringCellValue())
.isEqualTo("http://was-oth:8084/mcp/oth.cmm.customer.detail");
assertThat(workbook.getSheet("Request In").getRow(8).getCell(5).getStringCellValue())
.isEqualTo("customerId");
assertThat(workbook.getSheet("Request In").getRow(8).getCell(7).getStringCellValue())
.isEqualTo("8");
assertThat(workbook.getSheet("Request In").getRow(32).getCell(2).getStringCellValue())
.contains("customerId", "12345678");
assertThat(workbook.getSheet("Response Out").getRow(8).getCell(5).getStringCellValue())
.isEqualTo("resultData");
}
}
@Test
void rejectsProgramRequestWithoutSelectedSheet() {
assertThatThrownBy(() -> service.generateProgram(
new DocumentGenerationRequest(tool, "1.0", false, false, false)))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("한 개 이상의 시트");
}
}