From ccabd1187b14e8e50ce34817493bcb0bc14de7de Mon Sep 17 00:00:00 2001 From: binzz Date: Wed, 12 Aug 2026 10:11:48 +0900 Subject: [PATCH] feat: add Excel document generator --- dap-gateway/build.gradle | 3 + .../document/DocumentGenerationRequest.java | 11 + .../document/DocumentGeneratorController.java | 56 ++ .../document/DocumentGeneratorService.java | 534 +++++++++++++++ .../dap/mcg/document/GeneratedDocument.java | 4 + .../excel/interface-definition-template.xlsx | Bin 0 -> 7170 bytes .../excel/program-definition-template.xlsx | Bin 0 -> 7065 bytes .../main/resources/static/admin/scaffold.html | 638 ++++++++++++++++++ .../DocumentGeneratorControllerTest.java | 57 ++ .../DocumentGeneratorServiceTest.java | 130 ++++ 10 files changed, 1433 insertions(+) create mode 100644 dap-gateway/src/main/java/io/shinhanlife/dap/mcg/document/DocumentGenerationRequest.java create mode 100644 dap-gateway/src/main/java/io/shinhanlife/dap/mcg/document/DocumentGeneratorController.java create mode 100644 dap-gateway/src/main/java/io/shinhanlife/dap/mcg/document/DocumentGeneratorService.java create mode 100644 dap-gateway/src/main/java/io/shinhanlife/dap/mcg/document/GeneratedDocument.java create mode 100644 dap-gateway/src/main/resources/document-templates/excel/interface-definition-template.xlsx create mode 100644 dap-gateway/src/main/resources/document-templates/excel/program-definition-template.xlsx create mode 100644 dap-gateway/src/test/java/io/shinhanlife/dap/mcg/document/DocumentGeneratorControllerTest.java create mode 100644 dap-gateway/src/test/java/io/shinhanlife/dap/mcg/document/DocumentGeneratorServiceTest.java diff --git a/dap-gateway/build.gradle b/dap-gateway/build.gradle index 3cd20c29..d0be68a0 100644 --- a/dap-gateway/build.gradle +++ b/dap-gateway/build.gradle @@ -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' diff --git a/dap-gateway/src/main/java/io/shinhanlife/dap/mcg/document/DocumentGenerationRequest.java b/dap-gateway/src/main/java/io/shinhanlife/dap/mcg/document/DocumentGenerationRequest.java new file mode 100644 index 00000000..dbfa7e6c --- /dev/null +++ b/dap-gateway/src/main/java/io/shinhanlife/dap/mcg/document/DocumentGenerationRequest.java @@ -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) { +} diff --git a/dap-gateway/src/main/java/io/shinhanlife/dap/mcg/document/DocumentGeneratorController.java b/dap-gateway/src/main/java/io/shinhanlife/dap/mcg/document/DocumentGeneratorController.java new file mode 100644 index 00000000..cdb296c1 --- /dev/null +++ b/dap-gateway/src/main/java/io/shinhanlife/dap/mcg/document/DocumentGeneratorController.java @@ -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 generateProgram(@RequestBody DocumentGenerationRequest request) { + return download(documentGeneratorService.generateProgram(request)); + } + + @PostMapping(value = "/interface", produces = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet") + public ResponseEntity generateInterface(@RequestBody DocumentGenerationRequest request) { + return download(documentGeneratorService.generateInterface(request)); + } + + @ExceptionHandler(IllegalArgumentException.class) + public ResponseEntity> invalidRequest(IllegalArgumentException exception) { + return ResponseEntity.badRequest().body(Map.of("error", exception.getMessage())); + } + + private ResponseEntity 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()); + } +} diff --git a/dap-gateway/src/main/java/io/shinhanlife/dap/mcg/document/DocumentGeneratorService.java b/dap-gateway/src/main/java/io/shinhanlife/dap/mcg/document/DocumentGeneratorService.java new file mode 100644 index 00000000..f1dac09b --- /dev/null +++ b/dap-gateway/src/main/java/io/shinhanlife/dap/mcg/document/DocumentGeneratorService.java @@ -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> 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 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 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 schemaFields(Map schema) { + List fields = new ArrayList<>(); + collectSchemaFields(schema, "", Set.of(), fields); + return fields; + } + + @SuppressWarnings("unchecked") + private void collectSchemaFields(Map schema, String prefix, Set inheritedRequired, + List fields) { + if (schema == null) { + return; + } + Object requiredValue = schema.get("required"); + Set 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 node = (Map) 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) items, path + "[]", Set.of(), fields); + } + } + } + + private String schemaLength(Map 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 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 schema) { + if (schema == null) { + return Map.of(); + } + Object type = schema.get("type"); + if ("object".equals(type) || schema.get("properties") instanceof Map) { + Map 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) 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) items)) : List.of(); + default -> ""; + }; + } + + 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) { + } +} diff --git a/dap-gateway/src/main/java/io/shinhanlife/dap/mcg/document/GeneratedDocument.java b/dap-gateway/src/main/java/io/shinhanlife/dap/mcg/document/GeneratedDocument.java new file mode 100644 index 00000000..6b1c8950 --- /dev/null +++ b/dap-gateway/src/main/java/io/shinhanlife/dap/mcg/document/GeneratedDocument.java @@ -0,0 +1,4 @@ +package io.shinhanlife.dap.mcg.document; + +public record GeneratedDocument(String fileName, byte[] content) { +} diff --git a/dap-gateway/src/main/resources/document-templates/excel/interface-definition-template.xlsx b/dap-gateway/src/main/resources/document-templates/excel/interface-definition-template.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..e7cba495c85d4c2d4a17c33c4e951f6fae15960f GIT binary patch literal 7170 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 ZXysR3*Hp#Cy1gIH%_sCG*jK+){~yN}^(g=V literal 0 HcmV?d00001 diff --git a/dap-gateway/src/main/resources/static/admin/scaffold.html b/dap-gateway/src/main/resources/static/admin/scaffold.html index 701483fd..2f66d183 100644 --- a/dap-gateway/src/main/resources/static/admin/scaffold.html +++ b/dap-gateway/src/main/resources/static/admin/scaffold.html @@ -12,6 +12,383 @@ +