Compare commits
2 Commits
e250b1149c
...
9021154481
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9021154481 | ||
|
|
494fbe9c45 |
@@ -0,0 +1,72 @@
|
||||
package io.shinhanlife.dap.mcg.presentation;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.core.ParameterizedTypeReference;
|
||||
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.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.client.RestClient;
|
||||
import org.springframework.web.client.RestClientResponseException;
|
||||
|
||||
/**
|
||||
* Gateway 화면에서 들어온 DTO 엑셀 요청을 실제 DTO 클래스가 있는 Tool Pod로 전달한다.
|
||||
* 브라우저가 동일 출처(8081)만 호출하도록 하여 CORS와 배포 주소 차이를 숨긴다.
|
||||
*/
|
||||
@RestController
|
||||
public class DtoDownloadProxyController {
|
||||
|
||||
private static final MediaType XLSX_MEDIA_TYPE = MediaType.parseMediaType(
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
|
||||
|
||||
private final RestClient restClient;
|
||||
private final String toolPodUrl;
|
||||
|
||||
public DtoDownloadProxyController(
|
||||
RestClient.Builder restClientBuilder,
|
||||
@Value("${mcp.gateway.fallback.default-url:http://localhost:8084}") String toolPodUrl) {
|
||||
this.restClient = restClientBuilder.build();
|
||||
this.toolPodUrl = toolPodUrl.replaceAll("/+$", "");
|
||||
}
|
||||
|
||||
@GetMapping("/dto-download/options")
|
||||
public List<String> options() {
|
||||
// 셀렉트박스에 표시할 DTO 이름 목록을 Tool Pod에서 조회한다.
|
||||
List<String> options = restClient.get()
|
||||
.uri(toolPodUrl + "/dto-download/options")
|
||||
.retrieve()
|
||||
.body(new ParameterizedTypeReference<>() {});
|
||||
return options == null ? List.of() : options;
|
||||
}
|
||||
|
||||
@GetMapping("/dto-download/{dtoName}")
|
||||
public ResponseEntity<byte[]> download(@PathVariable String dtoName) {
|
||||
byte[] workbook;
|
||||
try {
|
||||
// 생성된 엑셀 바이트를 그대로 브라우저에 전달한다.
|
||||
workbook = restClient.get()
|
||||
.uri(toolPodUrl + "/dto-download/{dtoName}", dtoName)
|
||||
.retrieve()
|
||||
.body(byte[].class);
|
||||
} catch (RestClientResponseException error) {
|
||||
// 형식 불일치 등의 상태 코드와 오류 메시지도 변경 없이 전달한다.
|
||||
return ResponseEntity.status(error.getStatusCode())
|
||||
.contentType(error.getResponseHeaders() != null
|
||||
&& error.getResponseHeaders().getContentType() != null
|
||||
? error.getResponseHeaders().getContentType() : MediaType.TEXT_PLAIN)
|
||||
.body(error.getResponseBodyAsByteArray());
|
||||
}
|
||||
String fileName = dtoName + ".xlsx";
|
||||
return ResponseEntity.ok()
|
||||
.contentType(XLSX_MEDIA_TYPE)
|
||||
.contentLength(workbook == null ? 0 : workbook.length)
|
||||
.header(HttpHeaders.CONTENT_DISPOSITION,
|
||||
ContentDisposition.attachment().filename(fileName).build().toString())
|
||||
.body(workbook == null ? new byte[0] : workbook);
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,10 @@
|
||||
.result { background:#101012; border:1px solid var(--line); border-radius:8px; padding:14px; white-space:pre-wrap; overflow:auto; max-height:520px; font:13px/1.55 ui-monospace,SFMono-Regular,Consolas,monospace; }
|
||||
.case-list { display:grid; gap:8px; margin-top:12px; } .case-row { display:flex; align-items:center; gap:8px; padding:9px; border:1px solid var(--line); border-radius:8px; } .case-row main { padding:0; margin:0; flex:1; min-width:0; } .case-row strong,.case-row small { display:block; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } .case-row small { color:var(--muted); margin-top:3px; }
|
||||
.run-log { margin-top:12px; font:12px/1.55 ui-monospace,monospace; color:var(--muted); white-space:pre-wrap; max-height:220px; overflow:auto; }
|
||||
.modal-backdrop { position:fixed; inset:0; z-index:100; display:flex; align-items:center; justify-content:center; padding:20px; background:rgba(0,0,0,.68); }
|
||||
.modal { width:min(420px,100%); padding:20px; background:var(--surface); border:1px solid var(--line); border-radius:12px; box-shadow:0 24px 70px rgba(0,0,0,.5); }
|
||||
.modal-header { display:flex; align-items:center; justify-content:space-between; gap:12px; margin-bottom:18px; } .modal-header h2 { margin:0; font-size:17px; }
|
||||
.modal-close { padding:4px 8px; font-size:18px; line-height:1; } .modal-actions { display:flex; justify-content:flex-end; margin-top:18px; }
|
||||
.footer-note { margin-top:18px; color:#71717a; font-size:12px; } @media (max-width:900px) { .grid { grid-template-columns:1fr; } .header-inner { padding:0 16px; } main { padding:20px 16px; } }
|
||||
</style>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
@@ -49,6 +53,7 @@
|
||||
</nav>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<button id="dtoDownloadButton" class="primary" type="button">DTO 다운로드</button>
|
||||
<span class="badge" id="manifestStatus" style="border:1px solid #3f3f46; border-radius:99px; padding:4px 8px; color:#a1a1aa; font-size:11px; font-family:ui-monospace,monospace;">Manifest loading</span>
|
||||
<span class="text-[10px] uppercase tracking-widest px-2 py-1 rounded font-bold" style="background:rgba(59,130,246,0.1); color:#60a5fa; border:1px solid rgba(59,130,246,0.2);">v0.0.1</span>
|
||||
</div>
|
||||
@@ -68,6 +73,19 @@
|
||||
</div>
|
||||
<p class="footer-note">이 화면은 현재 Tool Pod의 <code>/tool-manifest</code>와 <code>/mcp/{toolName}</code>만 사용합니다. 저장된 케이스는 이 브라우저의 localStorage에만 보관됩니다.</p>
|
||||
</main>
|
||||
<div id="dtoDownloadModal" class="modal-backdrop hidden" role="dialog" aria-modal="true" aria-labelledby="dtoDownloadTitle">
|
||||
<div class="modal">
|
||||
<div class="modal-header">
|
||||
<h2 id="dtoDownloadTitle">DTO 다운로드</h2>
|
||||
<button id="dtoModalCloseButton" class="modal-close" type="button" aria-label="닫기">×</button>
|
||||
</div>
|
||||
<label for="dtoSelect">DTO 선택</label>
|
||||
<select id="dtoSelect">
|
||||
<option value="">DTO 목록 불러오는 중...</option>
|
||||
</select>
|
||||
<div class="modal-actions"><button id="dtoConfirmButton" class="primary" type="button">확인</button></div>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
(() => {
|
||||
const STORAGE_KEY = 'axhub.tool-test-console.cases.v1';
|
||||
@@ -75,6 +93,61 @@
|
||||
const $ = id => document.getElementById(id);
|
||||
const select = $('toolSelect'), args = $('arguments'), result = $('result');
|
||||
|
||||
async function loadDtoOptions() {
|
||||
// 서버가 itrf/mci/**/io 패키지에서 찾은 DTO 목록으로 셀렉트박스를 구성한다.
|
||||
const dtoSelect = $('dtoSelect');
|
||||
try {
|
||||
const response = await fetch('/dto-download/options', { headers: { 'Cache-Control': 'no-cache' } });
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
const dtoNames = await response.json();
|
||||
dtoSelect.innerHTML = '';
|
||||
for (const dtoName of dtoNames) {
|
||||
const option = document.createElement('option');
|
||||
option.value = dtoName;
|
||||
option.textContent = `${dtoName}.java`;
|
||||
dtoSelect.appendChild(option);
|
||||
}
|
||||
if (!dtoNames.length) dtoSelect.innerHTML = '<option value="">DTO 파일이 없습니다.</option>';
|
||||
} catch (error) {
|
||||
dtoSelect.innerHTML = '<option value="">DTO 목록을 불러오지 못했습니다.</option>';
|
||||
}
|
||||
}
|
||||
function openDtoModal() { $('dtoDownloadModal').classList.remove('hidden'); loadDtoOptions(); $('dtoSelect').focus(); }
|
||||
function closeDtoModal() { $('dtoDownloadModal').classList.add('hidden'); $('dtoDownloadButton').focus(); }
|
||||
async function downloadSelectedDto() {
|
||||
// 선택한 DTO의 엑셀 생성 API를 호출하고 응답 Blob을 브라우저 다운로드로 저장한다.
|
||||
const dtoName = $('dtoSelect').value;
|
||||
if (!dtoName) return alert('다운로드할 DTO를 선택해주세요.');
|
||||
const confirmButton = $('dtoConfirmButton');
|
||||
confirmButton.disabled = true;
|
||||
try {
|
||||
const response = await fetch(`/dto-download/${encodeURIComponent(dtoName)}`);
|
||||
if (!response.ok) {
|
||||
// 422는 GlowTrgmField 메타데이터가 없어 설계서 양식으로 변환할 수 없는 경우다.
|
||||
const message = (await response.text()).trim();
|
||||
throw new Error(response.status === 422
|
||||
? 'dto의 내용이 엑셀파일 양식에 맞지않습니다 파일을 확인해주세요'
|
||||
: message || (response.status === 404
|
||||
? `${dtoName}.xlsx 파일을 찾을 수 없습니다.`
|
||||
: `다운로드에 실패했습니다. (HTTP ${response.status})`));
|
||||
}
|
||||
const blob = await response.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = `${dtoName}.xlsx`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
closeDtoModal();
|
||||
} catch (error) {
|
||||
alert(error.message);
|
||||
} finally {
|
||||
confirmButton.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
function loadCases() { try { return JSON.parse(localStorage.getItem(STORAGE_KEY) || '[]'); } catch (_) { return []; } }
|
||||
function persistCases() { localStorage.setItem(STORAGE_KEY, JSON.stringify(state.cases)); renderCases(); }
|
||||
function requestId() { return crypto.randomUUID ? crypto.randomUUID() : `${Date.now()}-${Math.random().toString(16).slice(2)}`; }
|
||||
@@ -252,9 +325,14 @@
|
||||
$('executeButton').addEventListener('click', async () => { try { await execute(); } catch (error) { $('httpStatus').textContent = '입력 오류'; $('httpStatus').className = 'badge fail'; result.textContent = error.message; } });
|
||||
$('runAllButton').addEventListener('click', runSavedCases);
|
||||
$('clearCasesButton').addEventListener('click', () => { if (confirm('저장된 테스트 케이스를 모두 삭제할까요?')) { state.cases = []; persistCases(); } });
|
||||
$('dtoDownloadButton').addEventListener('click', openDtoModal);
|
||||
$('dtoModalCloseButton').addEventListener('click', closeDtoModal);
|
||||
$('dtoConfirmButton').addEventListener('click', downloadSelectedDto);
|
||||
$('dtoDownloadModal').addEventListener('click', event => { if (event.target === $('dtoDownloadModal')) closeDtoModal(); });
|
||||
document.addEventListener('keydown', event => { if (event.key === 'Escape' && !$('dtoDownloadModal').classList.contains('hidden')) closeDtoModal(); });
|
||||
$('caseList').addEventListener('click', async event => { const id = event.target.dataset.run || event.target.dataset.delete; if (!id) return; const item = state.cases.find(candidate => candidate.id === id); if (event.target.dataset.delete) { state.cases = state.cases.filter(candidate => candidate.id !== id); persistCases(); return; } const tool = state.tools.find(candidate => candidate.name === item.toolName); try { await execute(tool, item.arguments); } catch (error) { result.textContent = error.message; } });
|
||||
renderCases(); loadManifest();
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
</html>
|
||||
|
||||
@@ -6,4 +6,5 @@ plugins {
|
||||
dependencies {
|
||||
// MCP Server, Tool 공통 처리, MCI/EAI 연동 기반은 dap-was-lib에서 상속합니다.
|
||||
implementation project(':dap-was-lib')
|
||||
implementation 'org.apache.poi:poi-ooxml:5.3.0'
|
||||
}
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
package io.shinhanlife.dap.mcc.infra.itrf.mci.ncm.d.io;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||
|
||||
import io.shinhanlife.glow.communication.annotation.GlowTrgmField;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.Setter;
|
||||
|
||||
/**
|
||||
* ONCMD0030_O 매핑 DTO
|
||||
*/
|
||||
@NoArgsConstructor
|
||||
@Getter
|
||||
@Setter
|
||||
public class ONCMD0030_O {
|
||||
|
||||
@GlowTrgmField(order = 1, description = "고객스마트정보조회OutDto", type = "gm")
|
||||
private List<CstSmartIfinOutDto> cstSmartIfinOutDto;
|
||||
|
||||
@GlowTrgmField(order = 1, description = "고객스마트정보조회OutDto2", type = "gm")
|
||||
private List<CstSmartIfinOutDto2> cstSmartIfinOutDto2;
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
@NoArgsConstructor
|
||||
@Getter
|
||||
@Setter
|
||||
public static class CstSmartIfinOutDto {
|
||||
|
||||
@GlowTrgmField(order = 1, length = 1, description = "동의여부")
|
||||
private String agrYn;
|
||||
|
||||
@GlowTrgmField(order = 2, length = 12, description = "고객번호")
|
||||
private String csNo;
|
||||
|
||||
@GlowTrgmField(order = 3, length = 50, description = "주민등록번호")
|
||||
private String rdreNo;
|
||||
|
||||
@GlowTrgmField(order = 4, length = 20, description = "등록일시")
|
||||
private String rgiDt;
|
||||
|
||||
}
|
||||
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
@NoArgsConstructor
|
||||
@Getter
|
||||
@Setter
|
||||
public static class CstSmartIfinOutDto2 {
|
||||
|
||||
@GlowTrgmField(order = 1, length = 1, description = "동의여부")
|
||||
private String agrYnaa;
|
||||
|
||||
@GlowTrgmField(order = 2, length = 12, description = "고객번호")
|
||||
private String csNoaa;
|
||||
|
||||
@GlowTrgmField(order = 3, length = 50, description = "주민등록번호")
|
||||
private String rdreNoaa;
|
||||
|
||||
@GlowTrgmField(order = 4, length = 20, description = "등록일시")
|
||||
private String rgiDtaa;
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,424 @@
|
||||
package io.shinhanlife.dap.mcc.presentation;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.lang.annotation.Annotation;
|
||||
import java.lang.reflect.Field;
|
||||
import java.lang.reflect.Method;
|
||||
import java.lang.reflect.ParameterizedType;
|
||||
import java.lang.reflect.Type;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.TreeMap;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
import org.apache.poi.ss.usermodel.*;
|
||||
import org.apache.poi.ss.util.CellRangeAddress;
|
||||
import org.apache.poi.xssf.usermodel.XSSFColor;
|
||||
import org.apache.poi.xssf.usermodel.XSSFWorkbook;
|
||||
import org.springframework.context.annotation.ClassPathScanningCandidateComponentProvider;
|
||||
import org.springframework.core.type.filter.RegexPatternTypeFilter;
|
||||
import org.springframework.http.ContentDisposition;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
/**
|
||||
* MCI DTO 클래스를 조회하고 인터페이스 설계서 형식의 엑셀 파일을 생성한다.
|
||||
* 외부 템플릿 파일에 의존하지 않고 Apache POI로 양식과 데이터를 모두 만든다.
|
||||
*/
|
||||
@RestController
|
||||
public class DtoExcelDownloadController {
|
||||
|
||||
private static final int FIRST_FIELD_ROW = 12;
|
||||
private static final int TEMPLATE_LAST_ROW = 35;
|
||||
private static final int COLUMN_COUNT = 20;
|
||||
private static final MediaType XLSX_MEDIA_TYPE = MediaType.parseMediaType(
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
|
||||
private static final String MCI_BASE_PACKAGE = "io.shinhanlife.dap.mcc.infra.itrf.mci";
|
||||
private static final String INVALID_DTO_MESSAGE =
|
||||
"dto의 내용이 엑셀파일 양식에 맞지않습니다 파일을 확인해주세요";
|
||||
|
||||
private final Map<String, String> dtoClasses;
|
||||
|
||||
public DtoExcelDownloadController() {
|
||||
this.dtoClasses = scanDtoClasses();
|
||||
}
|
||||
|
||||
@GetMapping("/dto-download/options")
|
||||
public List<String> options() {
|
||||
// 클래스패스에서 자동 검색된 DTO 목록을 화면에 제공한다.
|
||||
return List.copyOf(dtoClasses.keySet());
|
||||
}
|
||||
|
||||
@GetMapping("/dto-download/{dtoName}")
|
||||
public ResponseEntity<byte[]> download(@PathVariable String dtoName) throws IOException {
|
||||
// 스캔되지 않은 이름을 받아 임의 클래스를 조회하지 못하도록 제한한다.
|
||||
String className = dtoClasses.get(dtoName);
|
||||
if (className == null) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
byte[] workbook = createWorkbook(dtoName, className);
|
||||
String fileName = dtoName + ".xlsx";
|
||||
return ResponseEntity.ok()
|
||||
.contentType(XLSX_MEDIA_TYPE)
|
||||
.contentLength(workbook.length)
|
||||
.header(HttpHeaders.CONTENT_DISPOSITION,
|
||||
ContentDisposition.attachment().filename(fileName).build().toString())
|
||||
.body(workbook);
|
||||
}
|
||||
|
||||
@ExceptionHandler(DtoFormatException.class)
|
||||
public ResponseEntity<String> handleInvalidDto() {
|
||||
return ResponseEntity.status(HttpStatus.UNPROCESSABLE_ENTITY)
|
||||
.contentType(MediaType.parseMediaType("text/plain;charset=UTF-8"))
|
||||
.body(INVALID_DTO_MESSAGE);
|
||||
}
|
||||
|
||||
private byte[] createWorkbook(String dtoName, String className) throws IOException {
|
||||
// 요청마다 새 워크북을 생성하므로 여러 사용자의 다운로드가 서로 영향을 주지 않는다.
|
||||
try (XSSFWorkbook workbook = createTemplateWorkbook();
|
||||
ByteArrayOutputStream output = new ByteArrayOutputStream()) {
|
||||
Sheet sheet = workbook.getSheetAt(0);
|
||||
setText(sheet, 2, 2, dtoName);
|
||||
setText(sheet, 3, 2, dtoName);
|
||||
|
||||
List<FieldRow> fields;
|
||||
try {
|
||||
fields = describeFields(Class.forName(className));
|
||||
} catch (ReflectiveOperationException error) {
|
||||
throw new IOException("DTO class could not be inspected: " + className, error);
|
||||
}
|
||||
writeFields(sheet, fields);
|
||||
workbook.write(output);
|
||||
return output.toByteArray();
|
||||
}
|
||||
}
|
||||
|
||||
private XSSFWorkbook createTemplateWorkbook() {
|
||||
// 기준 문서의 시트명, 열 너비, 병합, 색상과 테두리를 코드로 재현한다.
|
||||
XSSFWorkbook workbook = new XSSFWorkbook();
|
||||
Sheet sheet = workbook.createSheet("대내");
|
||||
sheet.setDisplayGridlines(false);
|
||||
sheet.createFreezePane(0, 12);
|
||||
sheet.getPrintSetup().setLandscape(true);
|
||||
sheet.setRepeatingRows(new CellRangeAddress(11, 11, -1, -1));
|
||||
|
||||
double[] widths = {4.44, 8, 23.22, 23.22, 10, 14, 18, 11, 9, 7.44,
|
||||
8, 9.55, 9, 10.55, 11.44, 9, 13, 10.55, 14, 30};
|
||||
for (int column = 0; column < widths.length; column++) {
|
||||
sheet.setColumnWidth(column, (int) (widths[column] * 256));
|
||||
}
|
||||
|
||||
CellStyle titleStyle = style(workbook, "000000", "FFFFFF", true, (short) 14,
|
||||
HorizontalAlignment.CENTER, false);
|
||||
CellStyle sectionStyle = style(workbook, "F2F2F2", "000000", true, (short) 10,
|
||||
HorizontalAlignment.CENTER, false);
|
||||
CellStyle labelStyle = borderedStyle(workbook, "F2F2F2", true, HorizontalAlignment.CENTER);
|
||||
CellStyle inputStyle = borderedStyle(workbook, "C6D9F1", false, HorizontalAlignment.LEFT);
|
||||
CellStyle requiredStyle = borderedStyle(workbook, "C6D9F1", false, HorizontalAlignment.LEFT);
|
||||
CellStyle autoStyle = borderedStyle(workbook, "F2DCDB", false, HorizontalAlignment.LEFT);
|
||||
CellStyle userStyle = borderedStyle(workbook, "FFFFFF", false, HorizontalAlignment.LEFT);
|
||||
CellStyle headerStyle = borderedStyle(workbook, "D9D9D9", true, HorizontalAlignment.CENTER);
|
||||
headerStyle.setWrapText(true);
|
||||
CellStyle whiteDataStyle = borderedStyle(workbook, "FFFFFF", false, HorizontalAlignment.CENTER);
|
||||
CellStyle blueDataStyle = borderedStyle(workbook, "C6D9F1", false, HorizontalAlignment.CENTER);
|
||||
CellStyle pinkDataStyle = borderedStyle(workbook, "F2DCDB", false, HorizontalAlignment.CENTER);
|
||||
|
||||
createStyledRow(sheet, 0, 25.5f, titleStyle);
|
||||
merge(sheet, "A1:T1");
|
||||
setText(sheet, 0, 0, "인터페이스 설계서(대내)");
|
||||
|
||||
createStyledRow(sheet, 1, 18f, sectionStyle);
|
||||
merge(sheet, "A2:T2");
|
||||
setText(sheet, 1, 0, "기본정보");
|
||||
|
||||
String[] labels = {"코드", "한글명", "영문명", "암호화", "유형", "레코드구분자", "필드구분자"};
|
||||
for (int index = 0; index < labels.length; index++) {
|
||||
int rowIndex = index + 2;
|
||||
Row row = sheet.createRow(rowIndex);
|
||||
cell(row, 1, labelStyle).setCellValue(labels[index]);
|
||||
cell(row, 2, inputStyle);
|
||||
cell(row, 3, inputStyle);
|
||||
merge(sheet, "C" + (rowIndex + 1) + ":D" + (rowIndex + 1));
|
||||
}
|
||||
setText(sheet, 6, 2, "json");
|
||||
sheet.getRow(7).setHeightInPoints(24);
|
||||
sheet.getRow(8).setHeightInPoints(24);
|
||||
|
||||
for (int rowIndex = 3; rowIndex <= 5; rowIndex++) {
|
||||
Row row = sheet.getRow(rowIndex);
|
||||
CellStyle legendStyle = rowIndex == 3 ? requiredStyle : rowIndex == 4 ? autoStyle : userStyle;
|
||||
cell(row, 5, legendStyle);
|
||||
cell(row, 6, legendStyle);
|
||||
merge(sheet, "F" + (rowIndex + 1) + ":G" + (rowIndex + 1));
|
||||
}
|
||||
setText(sheet, 3, 7, "필수입력");
|
||||
setText(sheet, 4, 7, "필드자동채우기(메타시스템 연동시)");
|
||||
setText(sheet, 5, 7, "사용자입력(필요시)");
|
||||
|
||||
createStyledRow(sheet, 10, 18f, sectionStyle);
|
||||
merge(sheet, "A11:T11");
|
||||
setText(sheet, 10, 0, "필드정보");
|
||||
|
||||
String[] headers = {"NO", "Level", "한글명", "부모식별자(한글명)", "끝수여부", "영문명",
|
||||
"부모식별자(영문명)", "데이터유형", "필드길이", "SCALE", "기본값", "정렬기준",
|
||||
"채움문자", "암호화방식", "메타체크여부", "한글여부", "소수점포함여부",
|
||||
"마스킹여부", "마스킹패턴코드", "비고"};
|
||||
Row header = sheet.createRow(11);
|
||||
header.setHeightInPoints(30);
|
||||
for (int column = 0; column < headers.length; column++) {
|
||||
cell(header, column, headerStyle).setCellValue(headers[column]);
|
||||
}
|
||||
|
||||
for (int rowIndex = FIRST_FIELD_ROW; rowIndex <= TEMPLATE_LAST_ROW; rowIndex++) {
|
||||
Row row = sheet.createRow(rowIndex);
|
||||
row.setHeightInPoints(15.75f);
|
||||
for (int column = 0; column < COLUMN_COUNT; column++) {
|
||||
CellStyle dataStyle;
|
||||
if (column == 0 || column == 4 || (column >= 14 && column <= 16) || column == 19) {
|
||||
dataStyle = whiteDataStyle;
|
||||
} else if (column >= 1 && column <= 3) {
|
||||
dataStyle = blueDataStyle;
|
||||
} else {
|
||||
dataStyle = pinkDataStyle;
|
||||
}
|
||||
cell(row, column, dataStyle);
|
||||
}
|
||||
}
|
||||
return workbook;
|
||||
}
|
||||
|
||||
private CellStyle style(XSSFWorkbook workbook, String fillColor, String fontColor,
|
||||
boolean bold, short fontSize, HorizontalAlignment alignment,
|
||||
boolean bordered) {
|
||||
CellStyle style = workbook.createCellStyle();
|
||||
style.setAlignment(alignment);
|
||||
style.setVerticalAlignment(VerticalAlignment.CENTER);
|
||||
style.setFillForegroundColor(new XSSFColor(java.awt.Color.decode("#" + fillColor), null));
|
||||
style.setFillPattern(FillPatternType.SOLID_FOREGROUND);
|
||||
Font font = workbook.createFont();
|
||||
font.setFontName("맑은 고딕");
|
||||
font.setFontHeightInPoints(fontSize);
|
||||
font.setBold(bold);
|
||||
font.setColor("FFFFFF".equals(fontColor) ? IndexedColors.WHITE.getIndex() : IndexedColors.BLACK.getIndex());
|
||||
style.setFont(font);
|
||||
if (bordered) setBorders(style);
|
||||
return style;
|
||||
}
|
||||
|
||||
private CellStyle borderedStyle(XSSFWorkbook workbook, String fillColor,
|
||||
boolean bold, HorizontalAlignment alignment) {
|
||||
return style(workbook, fillColor, "000000", bold, (short) 9, alignment, true);
|
||||
}
|
||||
|
||||
private void setBorders(CellStyle style) {
|
||||
style.setBorderTop(BorderStyle.THIN);
|
||||
style.setBorderBottom(BorderStyle.THIN);
|
||||
style.setBorderLeft(BorderStyle.THIN);
|
||||
style.setBorderRight(BorderStyle.THIN);
|
||||
style.setTopBorderColor(IndexedColors.GREY_50_PERCENT.getIndex());
|
||||
style.setBottomBorderColor(IndexedColors.GREY_50_PERCENT.getIndex());
|
||||
style.setLeftBorderColor(IndexedColors.GREY_50_PERCENT.getIndex());
|
||||
style.setRightBorderColor(IndexedColors.GREY_50_PERCENT.getIndex());
|
||||
}
|
||||
|
||||
private void createStyledRow(Sheet sheet, int rowIndex, float height, CellStyle style) {
|
||||
Row row = sheet.createRow(rowIndex);
|
||||
row.setHeightInPoints(height);
|
||||
for (int column = 0; column < COLUMN_COUNT; column++) cell(row, column, style);
|
||||
}
|
||||
|
||||
private Cell cell(Row row, int column, CellStyle style) {
|
||||
Cell cell = row.createCell(column);
|
||||
cell.setCellStyle(style);
|
||||
return cell;
|
||||
}
|
||||
|
||||
private void merge(Sheet sheet, String range) {
|
||||
sheet.addMergedRegion(CellRangeAddress.valueOf(range));
|
||||
}
|
||||
|
||||
private void writeFields(Sheet sheet, List<FieldRow> fields) {
|
||||
// 기본 24행을 유지하고 필드가 더 많으면 마지막 행의 서식을 복제해 확장한다.
|
||||
int requiredRows = Math.max(fields.size(), TEMPLATE_LAST_ROW - FIRST_FIELD_ROW + 1);
|
||||
for (int offset = 0; offset < requiredRows; offset++) {
|
||||
int rowIndex = FIRST_FIELD_ROW + offset;
|
||||
Row row = sheet.getRow(rowIndex);
|
||||
if (row == null) {
|
||||
row = cloneTemplateRow(sheet, rowIndex);
|
||||
}
|
||||
clearRowValues(row);
|
||||
setNumber(row, 0, offset + 1);
|
||||
if (offset < fields.size()) {
|
||||
FieldRow field = fields.get(offset);
|
||||
setNumber(row, 1, field.level());
|
||||
setText(row, 2, field.description());
|
||||
setText(row, 3, field.parentDescription());
|
||||
setText(row, 5, field.name());
|
||||
setText(row, 6, field.parentName());
|
||||
setText(row, 7, field.dataType());
|
||||
if (field.length() > 0) {
|
||||
setNumber(row, 8, field.length());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private Row cloneTemplateRow(Sheet sheet, int rowIndex) {
|
||||
Row source = sheet.getRow(TEMPLATE_LAST_ROW);
|
||||
Row target = sheet.createRow(rowIndex);
|
||||
target.setHeight(source.getHeight());
|
||||
for (int column = 0; column < COLUMN_COUNT; column++) {
|
||||
Cell sourceCell = source.getCell(column, Row.MissingCellPolicy.CREATE_NULL_AS_BLANK);
|
||||
Cell targetCell = target.createCell(column);
|
||||
CellStyle style = sourceCell.getCellStyle();
|
||||
targetCell.setCellStyle(style);
|
||||
}
|
||||
return target;
|
||||
}
|
||||
|
||||
private void clearRowValues(Row row) {
|
||||
for (int column = 0; column < COLUMN_COUNT; column++) {
|
||||
Cell cell = row.getCell(column, Row.MissingCellPolicy.CREATE_NULL_AS_BLANK);
|
||||
cell.setBlank();
|
||||
}
|
||||
}
|
||||
|
||||
private List<FieldRow> describeFields(Class<?> rootClass) {
|
||||
List<FieldRow> output = new ArrayList<>();
|
||||
appendFields(rootClass, 1, "", "", output);
|
||||
return output;
|
||||
}
|
||||
|
||||
private void appendFields(Class<?> type, int level, String parentName,
|
||||
String parentDescription, List<FieldRow> output) {
|
||||
// 중첩 DTO와 List 요소 타입을 재귀적으로 펼쳐 Level 및 부모 식별자를 계산한다.
|
||||
List<Field> fields = new ArrayList<>(List.of(type.getDeclaredFields()));
|
||||
fields.removeIf(field -> field.isSynthetic());
|
||||
fields.sort(Comparator.comparingInt(this::fieldOrder));
|
||||
for (Field field : fields) {
|
||||
Annotation metadata = telegramMetadata(field);
|
||||
// 한글명, 순서, 길이를 알 수 없는 DTO는 설계서 양식으로 변환할 수 없다.
|
||||
if (metadata == null) {
|
||||
throw new DtoFormatException();
|
||||
}
|
||||
String description = annotationString(metadata, "description", field.getName());
|
||||
int length = annotationInt(metadata, "length", 0);
|
||||
Class<?> nestedType = nestedType(field);
|
||||
String dataType = annotationString(metadata, "type", simpleDataType(field));
|
||||
output.add(new FieldRow(level, description, parentDescription, field.getName(),
|
||||
parentName, dataType, length));
|
||||
if (nestedType != null && nestedType != type) {
|
||||
appendFields(nestedType, level + 1, field.getName(), description, output);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private int fieldOrder(Field field) {
|
||||
return annotationInt(telegramMetadata(field), "order", Integer.MAX_VALUE);
|
||||
}
|
||||
|
||||
private Annotation telegramMetadata(Field field) {
|
||||
for (Annotation annotation : field.getDeclaredAnnotations()) {
|
||||
if (annotation.annotationType().getSimpleName().equals("GlowTrgmField")) {
|
||||
return annotation;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private String annotationString(Annotation annotation, String methodName, String fallback) {
|
||||
Object value = annotationValue(annotation, methodName);
|
||||
return value instanceof String text && !text.isBlank() ? text : fallback;
|
||||
}
|
||||
|
||||
private int annotationInt(Annotation annotation, String methodName, int fallback) {
|
||||
Object value = annotationValue(annotation, methodName);
|
||||
return value instanceof Number number ? number.intValue() : fallback;
|
||||
}
|
||||
|
||||
private Object annotationValue(Annotation annotation, String methodName) {
|
||||
if (annotation == null) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
Method method = annotation.annotationType().getMethod(methodName);
|
||||
return method.invoke(annotation);
|
||||
} catch (ReflectiveOperationException ignored) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private Class<?> nestedType(Field field) {
|
||||
Class<?> type = field.getType();
|
||||
if (List.class.isAssignableFrom(type) && field.getGenericType() instanceof ParameterizedType generic) {
|
||||
Type argument = generic.getActualTypeArguments()[0];
|
||||
if (argument instanceof Class<?> itemType && isDtoType(itemType)) {
|
||||
return itemType;
|
||||
}
|
||||
}
|
||||
return isDtoType(type) ? type : null;
|
||||
}
|
||||
|
||||
private boolean isDtoType(Class<?> type) {
|
||||
return !type.isPrimitive()
|
||||
&& !type.getName().startsWith("java.")
|
||||
&& !type.isEnum();
|
||||
}
|
||||
|
||||
private String simpleDataType(Field field) {
|
||||
if (List.class.isAssignableFrom(field.getType())) {
|
||||
return "List";
|
||||
}
|
||||
return field.getType().getSimpleName();
|
||||
}
|
||||
|
||||
private void setText(Sheet sheet, int row, int column, String value) {
|
||||
setText(sheet.getRow(row), column, value);
|
||||
}
|
||||
|
||||
private void setText(Row row, int column, String value) {
|
||||
row.getCell(column, Row.MissingCellPolicy.CREATE_NULL_AS_BLANK).setCellValue(value == null ? "" : value);
|
||||
}
|
||||
|
||||
private void setNumber(Row row, int column, int value) {
|
||||
row.getCell(column, Row.MissingCellPolicy.CREATE_NULL_AS_BLANK).setCellValue(value);
|
||||
}
|
||||
|
||||
private Map<String, String> scanDtoClasses() {
|
||||
// itrf.mci 하위의 모든 io 패키지를 검색하므로 신규 DTO 추가 시 하드코딩이 필요 없다.
|
||||
ClassPathScanningCandidateComponentProvider scanner =
|
||||
new ClassPathScanningCandidateComponentProvider(false);
|
||||
scanner.addIncludeFilter(new RegexPatternTypeFilter(
|
||||
Pattern.compile(".*\\.itrf\\.mci\\..*\\.io\\.[^.]+$")));
|
||||
|
||||
Map<String, String> classes = new TreeMap<>();
|
||||
scanner.findCandidateComponents(MCI_BASE_PACKAGE).forEach(candidate -> {
|
||||
String className = candidate.getBeanClassName();
|
||||
if (className == null || className.contains("$")) {
|
||||
return;
|
||||
}
|
||||
String simpleName = className.substring(className.lastIndexOf('.') + 1);
|
||||
String previous = classes.putIfAbsent(simpleName, className);
|
||||
if (previous != null) {
|
||||
throw new IllegalStateException("Duplicate DTO class name: " + simpleName);
|
||||
}
|
||||
});
|
||||
return Map.copyOf(classes);
|
||||
}
|
||||
|
||||
private record FieldRow(int level, String description, String parentDescription,
|
||||
String name, String parentName, String dataType, int length) {
|
||||
}
|
||||
|
||||
private static final class DtoFormatException extends RuntimeException {
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user