feat(document): report PDF generation with embedded NanumGothic (G-05)
openhtmltopdf renderer with bundled NanumGothic for Korean text; report authoring page gains PDF download wired to the new endpoint. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
660568f332
commit
23ff7d1799
@ -46,6 +46,9 @@ dependencies {
|
||||
// --- 메일 발송(자체 Postfix SMTP — 옥션 개설 통지·EDM·비밀번호 재설정) ---
|
||||
implementation 'org.springframework.boot:spring-boot-starter-mail'
|
||||
|
||||
// --- 서류 PDF 생성(M6 G-05) — HTML→PDF 경량 렌더러. NanumGothic 폰트 임베드(한글 보존). ---
|
||||
implementation 'com.openhtmltopdf:openhtmltopdf-pdfbox:1.0.10'
|
||||
|
||||
// --- JWT (행사 단위 RBAC 인증) ---
|
||||
implementation "io.jsonwebtoken:jjwt-api:${jjwtVersion}"
|
||||
runtimeOnly "io.jsonwebtoken:jjwt-impl:${jjwtVersion}"
|
||||
|
||||
@ -5,8 +5,14 @@ import com.zioinfo.kintex.auth.KintexPrincipal;
|
||||
import com.zioinfo.kintex.common.ApiResponse;
|
||||
import com.zioinfo.kintex.document.dto.DocumentReviewDto;
|
||||
import com.zioinfo.kintex.document.dto.DocumentTransitionRequest;
|
||||
import com.zioinfo.kintex.document.dto.GeneratedDocDto;
|
||||
import com.zioinfo.kintex.document.dto.MilestoneDto;
|
||||
import com.zioinfo.kintex.document.dto.ReportPdfRequest;
|
||||
import com.zioinfo.kintex.document.dto.RequiredDocumentDto;
|
||||
import org.springframework.http.ContentDisposition;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
@ -15,21 +21,24 @@ import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* M6 서류·마일스톤 API (SCR-22/23). 행사 RBAC 가드.
|
||||
* HWP/PDF 렌더(파일 큐)는 이번 스코프 제외 — 엔드포인트 미노출.
|
||||
* PDF 생성/다운로드는 실구현(G-05). HWP 렌더는 스코프 밖(후속) — 엔드포인트 미노출.
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/events/{eventId}")
|
||||
public class DocumentController {
|
||||
|
||||
private final DocumentService service;
|
||||
private final DocumentPdfService pdfService;
|
||||
private final EventAccessGuard guard;
|
||||
|
||||
public DocumentController(DocumentService service, EventAccessGuard guard) {
|
||||
public DocumentController(DocumentService service, DocumentPdfService pdfService, EventAccessGuard guard) {
|
||||
this.service = service;
|
||||
this.pdfService = pdfService;
|
||||
this.guard = guard;
|
||||
}
|
||||
|
||||
@ -67,4 +76,43 @@ public class DocumentController {
|
||||
String action = request == null ? null : request.action();
|
||||
return ApiResponse.ok(service.transition(eventId, docType, action));
|
||||
}
|
||||
|
||||
// ── PDF 생성/다운로드 (G-05) ──────────────────────────────────────
|
||||
|
||||
/** POST /documents/{docType}/pdf — SCR-23 웹폼 → 안전관리 계획서 PDF 생성(저장) → 다운로드 메타 반환. */
|
||||
@PostMapping("/documents/{docType}/pdf")
|
||||
public ApiResponse<GeneratedDocDto> generateReportPdf(@AuthenticationPrincipal KintexPrincipal principal,
|
||||
@PathVariable String eventId,
|
||||
@PathVariable String docType,
|
||||
@RequestBody(required = false) ReportPdfRequest form) {
|
||||
guard.requireEventAccess(principal, eventId);
|
||||
return ApiResponse.ok(pdfService.generateReport(eventId, docType, form));
|
||||
}
|
||||
|
||||
/** POST /document-summary/pdf — SCR-22 서류·마일스톤 현황 요약 PDF(서버 저장 데이터로 조립). */
|
||||
@PostMapping("/document-summary/pdf")
|
||||
public ApiResponse<GeneratedDocDto> generateSummaryPdf(@AuthenticationPrincipal KintexPrincipal principal,
|
||||
@PathVariable String eventId) {
|
||||
guard.requireEventAccess(principal, eventId);
|
||||
return ApiResponse.ok(pdfService.generateSummary(eventId));
|
||||
}
|
||||
|
||||
/** GET /documents/files/{fileId} — 생성된 PDF 다운로드(인증 + 행사 가드). 바이트 스트림. */
|
||||
@GetMapping("/documents/files/{fileId}")
|
||||
public ResponseEntity<byte[]> downloadPdf(@AuthenticationPrincipal KintexPrincipal principal,
|
||||
@PathVariable String eventId,
|
||||
@PathVariable String fileId,
|
||||
@org.springframework.web.bind.annotation.RequestParam(required = false) String name) {
|
||||
guard.requireEventAccess(principal, eventId);
|
||||
byte[] pdf = pdfService.load(eventId, fileId);
|
||||
String downloadName = (name == null || name.isBlank()) ? fileId : name;
|
||||
// 한글 파일명은 RFC 5987(filename*=UTF-8'') 로 인코딩 — 스택트레이스/민감정보 미포함.
|
||||
ContentDisposition cd = ContentDisposition.attachment()
|
||||
.filename(downloadName, StandardCharsets.UTF_8)
|
||||
.build();
|
||||
return ResponseEntity.ok()
|
||||
.header(HttpHeaders.CONTENT_DISPOSITION, cd.toString())
|
||||
.contentType(MediaType.APPLICATION_PDF)
|
||||
.body(pdf);
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,383 @@
|
||||
package com.zioinfo.kintex.document;
|
||||
|
||||
import com.openhtmltopdf.pdfboxout.PdfRendererBuilder;
|
||||
import com.zioinfo.kintex.common.error.ApiException;
|
||||
import com.zioinfo.kintex.common.error.ErrorCode;
|
||||
import com.zioinfo.kintex.document.dto.GeneratedDocDto;
|
||||
import com.zioinfo.kintex.document.dto.ReportPdfRequest;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* M6 서류 PDF 생성/서빙 서비스(G-05) — 웹폼 데이터를 A4 서식 HTML 로 조립해 PDF 로 렌더한다.
|
||||
*
|
||||
* <p>렌더러: openhtmltopdf(PDFBox). 한글은 리소스 동봉 폰트({@code /fonts/NanumGothic.ttf})를 임베드해 보존한다
|
||||
* (서버에 시스템 폰트가 없어도 안정). 파일 저장은 로그인 슬라이드/아바타와 동일 컨벤션:
|
||||
* {@code {kintex.upload.dir}/documents/{eventId}/} + <b>서버 UUID 파일명</b>. 원본/클라이언트 경로는 신뢰하지
|
||||
* 않으므로 경로 순회는 구조적으로 불가능하다. 다운로드는 인증 엔드포인트(GET)로 바이트 스트림한다.
|
||||
*
|
||||
* <p>HWP 는 스코프 밖(후속). 사용자 입력은 모두 HTML escape 하여 서식에 주입한다(XSS/서식 훼손 방지).
|
||||
*/
|
||||
@Service
|
||||
public class DocumentPdfService {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(DocumentPdfService.class);
|
||||
|
||||
/** 리소스 동봉 한글 폰트(임베드). 정적 상수 폰트 패밀리로 서식 전체에 적용. */
|
||||
private static final String FONT_RESOURCE = "/fonts/NanumGothic.ttf";
|
||||
private static final String FONT_FAMILY = "Nanum Gothic";
|
||||
|
||||
private final DocumentMapper mapper;
|
||||
private final Path documentsDir;
|
||||
|
||||
public DocumentPdfService(DocumentMapper mapper,
|
||||
@Value("${kintex.upload.dir:./data/uploads}") String uploadDir) {
|
||||
this.mapper = mapper;
|
||||
this.documentsDir = Paths.get(uploadDir).toAbsolutePath().normalize().resolve("documents");
|
||||
}
|
||||
|
||||
// ── 생성: 신고서류(안전관리 계획서) ─────────────────────────────────
|
||||
|
||||
/** SCR-23 웹폼 → 안전관리 계획서 PDF. form 은 미완성(빈 값 다수)이어도 미리보기 PDF 를 발급한다. */
|
||||
public GeneratedDocDto generateReport(String eventId, String docType, ReportPdfRequest form) {
|
||||
String title = blankTo(form == null ? null : form.docTitle(), "재해대처계획서");
|
||||
String html = buildReportHtml(title, form == null ? new ReportPdfRequest(
|
||||
null, null, null, null, null, null, null, null, null, null, null, null) : form);
|
||||
byte[] pdf = renderPdf(html);
|
||||
String fileName = safeFileBase(title) + "_" + shortDate() + ".pdf";
|
||||
return store(eventId, pdf, fileName);
|
||||
}
|
||||
|
||||
// ── 생성: 서류·마일스톤 현황 요약 ─────────────────────────────────
|
||||
|
||||
/** SCR-22 "자동 문서 생성" → 서류 준비 현황 요약 PDF(서버 저장 데이터로 조립, 폼 입력 불필요). */
|
||||
public GeneratedDocDto generateSummary(String eventId) {
|
||||
List<Map<String, Object>> milestones = mapper.findMilestones(eventId);
|
||||
List<Map<String, Object>> documents = mapper.findDocuments(eventId);
|
||||
List<Map<String, Object>> issues = mapper.findReviewIssues(eventId);
|
||||
String html = buildSummaryHtml(milestones, documents, issues);
|
||||
byte[] pdf = renderPdf(html);
|
||||
String fileName = "서류_준비현황_요약_" + shortDate() + ".pdf";
|
||||
return store(eventId, pdf, fileName);
|
||||
}
|
||||
|
||||
// ── 서빙(다운로드) ────────────────────────────────────────────────
|
||||
|
||||
/** 저장된 PDF 바이트 로드. fileId 는 서버 UUID(.pdf) — 안전성 검증 후 실재 파일만 통과. 없으면 404. */
|
||||
public byte[] load(String eventId, String fileId) {
|
||||
if (!isSafeName(fileId) || !fileId.toLowerCase().endsWith(".pdf")) {
|
||||
throw new ApiException(ErrorCode.NOT_FOUND, "요청한 문서를 찾을 수 없습니다.");
|
||||
}
|
||||
Path dir = eventDir(eventId);
|
||||
Path target = dir.resolve(fileId).normalize();
|
||||
if (!target.startsWith(dir) || !Files.isRegularFile(target)) {
|
||||
throw new ApiException(ErrorCode.NOT_FOUND, "요청한 문서를 찾을 수 없습니다.");
|
||||
}
|
||||
try {
|
||||
return Files.readAllBytes(target);
|
||||
} catch (IOException e) {
|
||||
log.warn("document pdf load failed: {}", e.getMessage());
|
||||
throw new ApiException(ErrorCode.NOT_FOUND, "요청한 문서를 찾을 수 없습니다.");
|
||||
}
|
||||
}
|
||||
|
||||
// ── 렌더/저장 ─────────────────────────────────────────────────────
|
||||
|
||||
private byte[] renderPdf(String html) {
|
||||
try (ByteArrayOutputStream os = new ByteArrayOutputStream()) {
|
||||
PdfRendererBuilder builder = new PdfRendererBuilder();
|
||||
builder.useFont(() -> fontStream(), FONT_FAMILY);
|
||||
builder.withHtmlContent(html, null);
|
||||
builder.toStream(os);
|
||||
builder.run();
|
||||
return os.toByteArray();
|
||||
} catch (Exception e) {
|
||||
// 스택트레이스 미노출 — 요약만 전달(보안 불변).
|
||||
log.error("document pdf render failed: {}", e.getMessage());
|
||||
throw new ApiException(ErrorCode.INTERNAL, "PDF 생성에 실패했습니다. 잠시 후 다시 시도해 주세요.");
|
||||
}
|
||||
}
|
||||
|
||||
private InputStream fontStream() {
|
||||
InputStream in = DocumentPdfService.class.getResourceAsStream(FONT_RESOURCE);
|
||||
if (in == null) {
|
||||
throw new ApiException(ErrorCode.INTERNAL, "PDF 서식 폰트를 불러올 수 없습니다.");
|
||||
}
|
||||
return in;
|
||||
}
|
||||
|
||||
private GeneratedDocDto store(String eventId, byte[] pdf, String fileName) {
|
||||
String fileId = UUID.randomUUID() + ".pdf"; // 서버 발급 식별자(원본명 미신뢰)
|
||||
try {
|
||||
Path dir = eventDir(eventId);
|
||||
Files.createDirectories(dir);
|
||||
Path target = dir.resolve(fileId).normalize();
|
||||
if (!target.startsWith(dir)) { // UUID라 사실상 불변식이나 방어적 확인
|
||||
throw new ApiException(ErrorCode.INTERNAL, "잘못된 저장 경로입니다.");
|
||||
}
|
||||
Files.write(target, pdf);
|
||||
} catch (IOException e) {
|
||||
log.error("document pdf store failed: {}", e.getMessage());
|
||||
throw new ApiException(ErrorCode.INTERNAL, "PDF 저장에 실패했습니다.");
|
||||
}
|
||||
String url = "/api/events/" + eventId + "/documents/files/" + fileId;
|
||||
return new GeneratedDocDto(fileId, fileName, url);
|
||||
}
|
||||
|
||||
private Path eventDir(String eventId) {
|
||||
// eventId 는 경로 세그먼트로 쓰이므로 안전성 확인(순회/구분자 차단).
|
||||
if (!isSafeName(eventId)) {
|
||||
throw new ApiException(ErrorCode.VALIDATION, "잘못된 행사 식별자입니다.");
|
||||
}
|
||||
return documentsDir.resolve(eventId).normalize();
|
||||
}
|
||||
|
||||
// ── HTML 서식 ─────────────────────────────────────────────────────
|
||||
|
||||
private String buildReportHtml(String title, ReportPdfRequest f) {
|
||||
String visitors = numOrDash(f.visitors());
|
||||
StringBuilder b = new StringBuilder();
|
||||
openHtml(b, esc(title));
|
||||
b.append("<h1 class=\"title\">").append(esc(title)).append("</h1>");
|
||||
|
||||
b.append("<table class=\"meta\"><tbody>");
|
||||
b.append("<tr><th>행 사 명</th><td colspan=\"3\">").append(dash(f.eventName())).append("</td></tr>");
|
||||
b.append("<tr><th>일 시</th><td>").append(fmtDate(f.eventDate()))
|
||||
.append("</td><th>장 소</th><td>").append(dash(f.venue())).append("</td></tr>");
|
||||
b.append("<tr><th>주최/주관</th><td>주식회사 킨텍스 조직위원회</td>")
|
||||
.append("<th>예상인원</th><td>").append(visitors.equals("—") ? "—" : visitors + "명").append("</td></tr>");
|
||||
b.append("</tbody></table>");
|
||||
|
||||
b.append("<h2>1. 안전관리 조직체계</h2><div class=\"box\">");
|
||||
b.append("총괄 안전책임자 ").append(dash2(f.safetyManager(), "(미지정)"));
|
||||
if (f.safetyPhone() != null && !f.safetyPhone().isBlank()) {
|
||||
b.append(" (").append(esc(f.safetyPhone())).append(")");
|
||||
}
|
||||
b.append(" 지휘 하에 현장 안전요원 ").append(dash2(f.guardCount(), "0"))
|
||||
.append("인을 배치한다. 각 홀 출입구에 안전요원을 상주시켜 밀집도를 관리하며, ")
|
||||
.append("정기 장내 방송으로 안전 수칙을 안내한다.</div>");
|
||||
|
||||
b.append("<h2>2. 비상 연락 체계</h2><ul>");
|
||||
b.append("<li>관할 소방서: ").append(dash(f.fireStation())).append("</li>");
|
||||
b.append("<li>관할 경찰서: ").append(dash(f.policeStation())).append("</li>");
|
||||
b.append("<li>의무·응급: ").append(dash(f.medical())).append("</li>");
|
||||
b.append("</ul>");
|
||||
|
||||
b.append("<h2>3. 주요 재해대처 방안</h2><ul>");
|
||||
b.append("<li>화재 시: 소방시설 즉시 가동 및 안내 방송을 통한 관람객 대피 유도</li>");
|
||||
b.append("<li>응급환자 발생 시: 현장 의무실 이송 및 119 구급대 협조 요청</li>");
|
||||
b.append("<li>정전 시: 비상 발전기 가동 및 유도등 점등 확인</li>");
|
||||
b.append("</ul>");
|
||||
|
||||
b.append("<h2>4. 위험물 관리</h2><div class=\"box\">")
|
||||
.append(dash2(f.hazardous(), "해당 없음")).append("</div>");
|
||||
|
||||
b.append("<div class=\"sign\"><div class=\"sign-date\">").append(fmtDate(f.eventDate())).append("</div>");
|
||||
b.append("<div class=\"sign-row\"><span>주식회사 킨텍스 대표이사</span>")
|
||||
.append("<span class=\"stamp\">KINTEX<br/>직인</span></div></div>");
|
||||
|
||||
closeHtml(b);
|
||||
return b.toString();
|
||||
}
|
||||
|
||||
private String buildSummaryHtml(List<Map<String, Object>> milestones,
|
||||
List<Map<String, Object>> documents,
|
||||
List<Map<String, Object>> issues) {
|
||||
StringBuilder b = new StringBuilder();
|
||||
openHtml(b, "서류 준비 현황 요약");
|
||||
b.append("<h1 class=\"title\">서류 준비 현황 요약</h1>");
|
||||
b.append("<p class=\"gen\">생성일: ").append(LocalDate.now()).append("</p>");
|
||||
|
||||
// 마일스톤
|
||||
b.append("<h2>1. 전시 마일스톤</h2>");
|
||||
if (milestones == null || milestones.isEmpty()) {
|
||||
b.append("<div class=\"box\">구성된 마일스톤이 없습니다.</div>");
|
||||
} else {
|
||||
b.append("<table class=\"grid\"><thead><tr><th>단계</th><th>상태</th><th>기한</th></tr></thead><tbody>");
|
||||
for (Map<String, Object> m : milestones) {
|
||||
b.append("<tr><td>").append(dash(str(m.get("label"))))
|
||||
.append("</td><td>").append(stateLabel(str(m.get("state"))))
|
||||
.append("</td><td>").append(dash(str(m.get("dueDate")))).append("</td></tr>");
|
||||
}
|
||||
b.append("</tbody></table>");
|
||||
}
|
||||
|
||||
// 서류 체크리스트 + 공정률
|
||||
int total = documents == null ? 0 : documents.size();
|
||||
long done = documents == null ? 0 : documents.stream()
|
||||
.filter(d -> !"pending".equals(String.valueOf(d.get("status")))).count();
|
||||
int pct = total == 0 ? 0 : (int) Math.round(done * 100.0 / total);
|
||||
b.append("<h2>2. 신고서류 체크리스트 (진척 ").append(pct).append("%)</h2>");
|
||||
if (total == 0) {
|
||||
b.append("<div class=\"box\">구성된 서류가 없습니다.</div>");
|
||||
} else {
|
||||
b.append("<table class=\"grid\"><thead><tr><th>서류</th><th>상태</th><th>기한</th></tr></thead><tbody>");
|
||||
for (Map<String, Object> d : documents) {
|
||||
b.append("<tr><td>").append(dash(str(d.get("name"))))
|
||||
.append("</td><td>").append(docStatusLabel(str(d.get("status"))))
|
||||
.append("</td><td>").append(dash(str(d.get("dueDate")))).append("</td></tr>");
|
||||
}
|
||||
b.append("</tbody></table>");
|
||||
}
|
||||
|
||||
// AI 검수 이슈
|
||||
b.append("<h2>3. AI 서류 검수</h2>");
|
||||
if (issues == null || issues.isEmpty()) {
|
||||
b.append("<div class=\"box\">검출된 불일치·누락이 없습니다.</div>");
|
||||
} else {
|
||||
b.append("<ul>");
|
||||
for (Map<String, Object> i : issues) {
|
||||
b.append("<li><b>").append(dash(str(i.get("title")))).append("</b> — ")
|
||||
.append(dash(str(i.get("description")))).append("</li>");
|
||||
}
|
||||
b.append("</ul>");
|
||||
}
|
||||
|
||||
b.append("<p class=\"note\">본 문서는 킨텍스 AI 전시·행사시스템이 자동 생성한 준비 현황 요약입니다. ")
|
||||
.append("공식 제출은 각 서류를 kxwp 시스템에 업로드하는 방식입니다.</p>");
|
||||
closeHtml(b);
|
||||
return b.toString();
|
||||
}
|
||||
|
||||
private void openHtml(StringBuilder b, String docTitle) {
|
||||
b.append("<?xml version=\"1.0\" encoding=\"UTF-8\"?>");
|
||||
b.append("<html><head><meta charset=\"UTF-8\"/><title>").append(docTitle).append("</title>");
|
||||
b.append("<style>");
|
||||
b.append("@page{size:A4;margin:2cm;}");
|
||||
b.append("*{font-family:'").append(FONT_FAMILY).append("',sans-serif;box-sizing:border-box;}");
|
||||
b.append("body{color:#1a1a1a;font-size:11pt;line-height:1.6;}");
|
||||
b.append("h1.title{text-align:center;font-size:18pt;margin:0 0 6pt;padding-bottom:8pt;border-bottom:2pt solid #1f29fc;}");
|
||||
b.append(".gen,.note{color:#666;font-size:9pt;}");
|
||||
b.append(".note{margin-top:18pt;padding-top:8pt;border-top:1pt solid #ccc;}");
|
||||
b.append("h2{font-size:12.5pt;margin:16pt 0 6pt;color:#1f29fc;}");
|
||||
b.append("table{width:100%;border-collapse:collapse;margin:4pt 0;}");
|
||||
b.append("table.meta th{background:#f2f4f8;width:18%;text-align:center;}");
|
||||
b.append("table.meta th,table.meta td{border:1pt solid #b8bfce;padding:6pt 8pt;font-size:10.5pt;}");
|
||||
b.append("table.grid th{background:#1f29fc;color:#fff;padding:6pt 8pt;font-size:10pt;}");
|
||||
b.append("table.grid td{border:1pt solid #d0d5e0;padding:5pt 8pt;font-size:10pt;}");
|
||||
b.append(".box{border:1pt solid #d0d5e0;background:#fafbfd;padding:8pt 10pt;border-radius:2pt;}");
|
||||
b.append("ul{margin:4pt 0;padding-left:18pt;}li{margin:2pt 0;}");
|
||||
b.append(".sign{margin-top:28pt;text-align:center;}");
|
||||
b.append(".sign-date{margin-bottom:10pt;font-size:11pt;}");
|
||||
b.append(".sign-row{display:inline-block;}");
|
||||
b.append(".sign-row span{font-size:12pt;font-weight:bold;margin-right:10pt;}");
|
||||
b.append(".stamp{display:inline-block;border:1.5pt solid #c0392b;color:#c0392b;border-radius:50%;");
|
||||
b.append("width:52pt;height:52pt;line-height:1.1;font-size:8pt;text-align:center;padding-top:12pt;}");
|
||||
b.append("</style></head><body>");
|
||||
}
|
||||
|
||||
private void closeHtml(StringBuilder b) {
|
||||
b.append("</body></html>");
|
||||
}
|
||||
|
||||
// ── 파생/포맷 헬퍼 ────────────────────────────────────────────────
|
||||
|
||||
private static String fmtDate(String v) {
|
||||
if (v == null || v.isBlank()) {
|
||||
return "—";
|
||||
}
|
||||
String[] p = v.split("-");
|
||||
if (p.length == 3) {
|
||||
return esc(p[0] + ". " + p[1] + ". " + p[2] + ".");
|
||||
}
|
||||
return esc(v);
|
||||
}
|
||||
|
||||
private static String stateLabel(String state) {
|
||||
return switch (state == null ? "" : state) {
|
||||
case "done" -> "완료";
|
||||
case "active" -> "진행중";
|
||||
default -> "대기";
|
||||
};
|
||||
}
|
||||
|
||||
private static String docStatusLabel(String status) {
|
||||
return switch (status == null ? "" : status) {
|
||||
case "approved" -> "승인";
|
||||
case "submitted" -> "제출";
|
||||
case "draft" -> "작성중";
|
||||
case "rejected" -> "반려";
|
||||
default -> "준비중";
|
||||
};
|
||||
}
|
||||
|
||||
private static String shortDate() {
|
||||
return LocalDate.now().toString().replace("-", "");
|
||||
}
|
||||
|
||||
/** 파일명 base(한글 허용, 경로/구분자·제어문자만 제거). */
|
||||
private static String safeFileBase(String s) {
|
||||
if (s == null || s.isBlank()) {
|
||||
return "document";
|
||||
}
|
||||
String cleaned = s.replaceAll("[\\\\/:*?\"<>|\\r\\n\\t]", "").trim();
|
||||
return cleaned.isEmpty() ? "document" : cleaned;
|
||||
}
|
||||
|
||||
private static boolean isSafeName(String name) {
|
||||
return name != null && !name.isBlank()
|
||||
&& !name.contains("/") && !name.contains("\\") && !name.contains("..");
|
||||
}
|
||||
|
||||
private static String blankTo(String v, String def) {
|
||||
return (v == null || v.isBlank()) ? def : v;
|
||||
}
|
||||
|
||||
/** escape + 빈값 → "—". */
|
||||
private static String dash(String v) {
|
||||
return (v == null || v.isBlank()) ? "—" : esc(v);
|
||||
}
|
||||
|
||||
/** escape + 빈값 → 지정 기본값. */
|
||||
private static String dash2(String v, String def) {
|
||||
return (v == null || v.isBlank()) ? esc(def) : esc(v);
|
||||
}
|
||||
|
||||
private static String numOrDash(String v) {
|
||||
if (v == null || v.isBlank()) {
|
||||
return "—";
|
||||
}
|
||||
try {
|
||||
return String.format("%,d", Long.parseLong(v.trim()));
|
||||
} catch (NumberFormatException e) {
|
||||
return esc(v);
|
||||
}
|
||||
}
|
||||
|
||||
private static String str(Object o) {
|
||||
return o == null ? null : String.valueOf(o);
|
||||
}
|
||||
|
||||
/** HTML/XML escape — 사용자 입력을 서식에 주입하기 전 필수(XHTML well-formed 보존). */
|
||||
private static String esc(String s) {
|
||||
if (s == null) {
|
||||
return "";
|
||||
}
|
||||
StringBuilder out = new StringBuilder(s.length() + 16);
|
||||
for (int i = 0; i < s.length(); i++) {
|
||||
char c = s.charAt(i);
|
||||
switch (c) {
|
||||
case '&' -> out.append("&");
|
||||
case '<' -> out.append("<");
|
||||
case '>' -> out.append(">");
|
||||
case '"' -> out.append(""");
|
||||
case '\'' -> out.append("'");
|
||||
default -> out.append(c);
|
||||
}
|
||||
}
|
||||
return out.toString();
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,15 @@
|
||||
package com.zioinfo.kintex.document.dto;
|
||||
|
||||
/**
|
||||
* 서류 PDF 생성 결과(M6 G-05). 프론트는 downloadUrl 을 인증 fetch 하여 Blob 다운로드한다.
|
||||
*
|
||||
* @param documentId 서버 발급 파일 식별자(UUID.pdf) — 경로 순회 불가
|
||||
* @param fileName 다운로드 파일명(한글 포함, Content-Disposition 에 인코딩되어 부착)
|
||||
* @param downloadUrl 인증 다운로드 경로(GET /api/events/{eventId}/documents/files/{documentId})
|
||||
*/
|
||||
public record GeneratedDocDto(
|
||||
String documentId,
|
||||
String fileName,
|
||||
String downloadUrl
|
||||
) {
|
||||
}
|
||||
@ -0,0 +1,22 @@
|
||||
package com.zioinfo.kintex.document.dto;
|
||||
|
||||
/**
|
||||
* SCR-23 신고서류 작성 → PDF 생성 요청(M6 G-05). 프론트 웹폼(ReportForm)과 1:1 필드 매핑.
|
||||
* 모든 값은 선택(미입력 시 서비스가 "—"/기본 문구로 대체) — 임시저장·미완성 상태에서도 미리보기 PDF 발급 가능.
|
||||
* 서버는 이 값들을 A4 안전관리 계획서 서식 HTML→PDF 로 렌더한다. HTML 은 서비스에서 escape 한다(주입 방지).
|
||||
*/
|
||||
public record ReportPdfRequest(
|
||||
String docTitle,
|
||||
String eventName,
|
||||
String eventDate,
|
||||
String venue,
|
||||
String visitors,
|
||||
String safetyManager,
|
||||
String safetyPhone,
|
||||
String guardCount,
|
||||
String fireStation,
|
||||
String policeStation,
|
||||
String medical,
|
||||
String hazardous
|
||||
) {
|
||||
}
|
||||
BIN
src/backend/src/main/resources/fonts/NanumGothic.ttf
Normal file
BIN
src/backend/src/main/resources/fonts/NanumGothic.ttf
Normal file
Binary file not shown.
@ -4,7 +4,8 @@
|
||||
* 구성: ①상단 마일스톤 진행 바(D-150→D-0) ②좌 신고서류 체크리스트(상태·D-데이·액션)
|
||||
* ③우 AI 서류 검수 카드(불일치·누락·kxwp 안내) + 전체 공정률.
|
||||
* ★ 실 API 배선(useQuery, 로딩/빈/에러 3상태) — GET /milestones · /documents · /documents/review.
|
||||
* HWP/PDF 생성은 파일 큐 후속 → 해당 버튼 disabled + 툴팁 유지.
|
||||
* 현황 요약 PDF 자동 생성은 실구현(POST /document-summary/pdf → 인증 다운로드).
|
||||
* HWP 생성은 스코프 밖(후속) → 해당 버튼 disabled + 툴팁 유지.
|
||||
*/
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
@ -18,8 +19,9 @@ import {
|
||||
IconTrendUp,
|
||||
IconWarning,
|
||||
} from '../../components/ui/icons';
|
||||
import { ApiRequestError } from '../../api/client';
|
||||
import { useResolvedEventId } from '../../hooks/useResolvedEventId';
|
||||
import { docsApi, type RequiredDocRow } from './docsApi';
|
||||
import { docsApi, downloadDoc, type RequiredDocRow } from './docsApi';
|
||||
import './docs.css';
|
||||
|
||||
type DocAction = 'view' | 'hwp' | 'write' | 'fix';
|
||||
@ -31,7 +33,7 @@ const ACTION_LABEL: Record<DocAction, string> = {
|
||||
fix: '수정',
|
||||
};
|
||||
|
||||
const RENDER_TOOLTIP = 'HWP/PDF 생성은 파일 큐 연동 후속(준비중)';
|
||||
const HWP_TOOLTIP = 'HWP 생성은 후속 연동(준비중) — 현황 요약은 PDF 로 제공됩니다';
|
||||
|
||||
/** 서류 상태 → 리스트 액션 파생. pending 은 액션 없음(준비중 표기). */
|
||||
function actionFor(status: RequiredDocRow['status']): DocAction | undefined {
|
||||
@ -56,6 +58,24 @@ function actionFor(status: RequiredDocRow['status']): DocAction | undefined {
|
||||
export function DocsMilestonePage({ onOpenAuthoring }: { onOpenAuthoring?: (docType: string) => void }) {
|
||||
const eventId = useResolvedEventId();
|
||||
const [toast, setToast] = useState<string | null>(null);
|
||||
const [summaryBusy, setSummaryBusy] = useState(false);
|
||||
|
||||
async function generateSummary() {
|
||||
if (!eventId) {
|
||||
setToast('대상 행사를 확인할 수 없습니다.');
|
||||
return;
|
||||
}
|
||||
setSummaryBusy(true);
|
||||
try {
|
||||
const doc = await docsApi.generateSummaryPdf(eventId);
|
||||
await downloadDoc(doc);
|
||||
setToast('현황 요약 PDF 생성 완료 — 다운로드를 시작합니다.');
|
||||
} catch (e) {
|
||||
setToast(e instanceof ApiRequestError ? e.message : '문서 생성에 실패했습니다.');
|
||||
} finally {
|
||||
setSummaryBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
const milestonesQ = useQuery({
|
||||
queryKey: ['docs-milestones', eventId],
|
||||
@ -240,10 +260,10 @@ export function DocsMilestonePage({ onOpenAuthoring }: { onOpenAuthoring?: (docT
|
||||
variant="ai"
|
||||
block
|
||||
leadingIcon={<IconSpark size={16} />}
|
||||
disabled
|
||||
title={RENDER_TOOLTIP}
|
||||
onClick={generateSummary}
|
||||
disabled={summaryBusy}
|
||||
>
|
||||
자동 문서 생성하기
|
||||
{summaryBusy ? '문서 생성 중…' : '자동 문서 생성하기 (PDF)'}
|
||||
</Button>
|
||||
</section>
|
||||
|
||||
@ -327,7 +347,7 @@ function DocRow({
|
||||
<Button
|
||||
variant={btnVariant}
|
||||
disabled={isRenderAction}
|
||||
title={isRenderAction ? RENDER_TOOLTIP : undefined}
|
||||
title={isRenderAction ? HWP_TOOLTIP : undefined}
|
||||
onClick={() => (isRenderAction ? undefined : onAction(doc, action))}
|
||||
>
|
||||
{ACTION_LABEL[action]}
|
||||
|
||||
@ -4,7 +4,7 @@
|
||||
* 구성: ①좌 웹폼(섹션 아코디언 + AI 자동채움 + 필수 검증) ②우 실시간 A4 문서 미리보기
|
||||
* ③하단 고정 액션 바(임시 저장·HWP 생성·PDF 생성·제출 / kxwp 릴레이 안내).
|
||||
* ★ 임시저장/제출은 실 API(POST /documents/{docType} — save|submit) 배선.
|
||||
* HWP/PDF 생성은 파일 큐 후속 → 해당 버튼 disabled + 툴팁 유지.
|
||||
* PDF 생성은 실구현(POST /documents/{docType}/pdf → 인증 다운로드). HWP 는 스코프 밖(후속) → disabled 유지.
|
||||
*/
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
@ -14,10 +14,10 @@ import { ErrorState, Skeleton } from '../../components/ui/States';
|
||||
import { IconChevronDown, IconDocument, IconSpark, IconWarning } from '../../components/ui/icons';
|
||||
import { ApiRequestError } from '../../api/client';
|
||||
import { useResolvedEventId } from '../../hooks/useResolvedEventId';
|
||||
import { docsApi, type DocAction } from './docsApi';
|
||||
import { docsApi, downloadDoc, type DocAction, type ReportPdfPayload } from './docsApi';
|
||||
import './docs.css';
|
||||
|
||||
const RENDER_TOOLTIP = 'HWP/PDF 생성은 파일 큐 연동 후속(준비중)';
|
||||
const HWP_TOOLTIP = 'HWP 생성은 후속 연동(준비중) — PDF 를 이용하세요';
|
||||
|
||||
/** 서류 표시명 → doc_type 코드(백엔드 required_document.doc_type). 미매핑 시 재해대처계획서. */
|
||||
const DOC_TYPE_CODE: Record<string, string> = {
|
||||
@ -121,6 +121,7 @@ export function ReportAuthoringPage({ docType = '재해대처계획서' }: { doc
|
||||
});
|
||||
const [showErrors, setShowErrors] = useState(false);
|
||||
const [toast, setToast] = useState<string | null>(null);
|
||||
const [pdfBusy, setPdfBusy] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!toast) return;
|
||||
@ -180,6 +181,25 @@ export function ReportAuthoringPage({ docType = '재해대처계획서' }: { doc
|
||||
transition.mutate('submit');
|
||||
}
|
||||
|
||||
const generatePdf = useCallback(async () => {
|
||||
if (!eventId) {
|
||||
setToast('대상 행사를 확인할 수 없습니다.');
|
||||
return;
|
||||
}
|
||||
setPdfBusy(true);
|
||||
setToast(null);
|
||||
try {
|
||||
const payload: ReportPdfPayload = { docTitle: docType, ...form };
|
||||
const doc = await docsApi.generateReportPdf(eventId, docTypeCode, payload);
|
||||
await downloadDoc(doc);
|
||||
setToast('PDF 생성 완료 — 다운로드를 시작합니다.');
|
||||
} catch (e) {
|
||||
setToast(e instanceof ApiRequestError ? e.message : 'PDF 생성에 실패했습니다.');
|
||||
} finally {
|
||||
setPdfBusy(false);
|
||||
}
|
||||
}, [eventId, docType, docTypeCode, form]);
|
||||
|
||||
if (status === 'loading') {
|
||||
return (
|
||||
<div className="kx-page" aria-busy="true">
|
||||
@ -473,11 +493,11 @@ export function ReportAuthoringPage({ docType = '재해대처계획서' }: { doc
|
||||
<Button variant="ghost" onClick={saveDraft} disabled={transition.isPending}>
|
||||
임시 저장
|
||||
</Button>
|
||||
<Button variant="secondary" disabled title={RENDER_TOOLTIP}>
|
||||
<Button variant="secondary" disabled title={HWP_TOOLTIP}>
|
||||
HWP 생성
|
||||
</Button>
|
||||
<Button variant="secondary" disabled title={RENDER_TOOLTIP}>
|
||||
PDF 생성
|
||||
<Button variant="secondary" onClick={generatePdf} disabled={pdfBusy}>
|
||||
{pdfBusy ? 'PDF 생성 중…' : 'PDF 생성'}
|
||||
</Button>
|
||||
<Button onClick={submit} disabled={transition.isPending}>
|
||||
제출
|
||||
|
||||
@ -6,9 +6,12 @@
|
||||
* - GET /api/events/{eventId}/documents → ApiResponse<RequiredDocRow[]>
|
||||
* - GET /api/events/{eventId}/documents/review → ApiResponse<DocReview>
|
||||
* - POST /api/events/{eventId}/documents/{docType} → ApiResponse<RequiredDocRow> (action: save|submit)
|
||||
* ★ HWP/PDF 렌더는 파일 큐 후속 — 엔드포인트 없음(프론트 버튼 disabled 유지).
|
||||
* - POST /api/events/{eventId}/documents/{docType}/pdf → ApiResponse<GeneratedDoc> (G-05 서류 PDF)
|
||||
* - POST /api/events/{eventId}/document-summary/pdf → ApiResponse<GeneratedDoc> (G-05 현황 요약 PDF)
|
||||
* - GET /api/events/{eventId}/documents/files/{fileId}→ application/pdf (인증 다운로드)
|
||||
* ★ HWP 렌더는 스코프 밖(후속) — HWP 버튼만 disabled 유지. PDF 는 실구현.
|
||||
*/
|
||||
import { api } from '../../api/client';
|
||||
import { api, getAccessToken, ApiRequestError } from '../../api/client';
|
||||
|
||||
export type MilestoneState = 'done' | 'active' | 'todo';
|
||||
export interface MilestoneRow {
|
||||
@ -41,16 +44,73 @@ export interface DocReview {
|
||||
/** save→임시저장(draft), submit→제출(submitted). */
|
||||
export type DocAction = 'save' | 'submit';
|
||||
|
||||
/** SCR-23 웹폼 → PDF 생성 페이로드(백엔드 ReportPdfRequest 와 1:1, 모두 선택). */
|
||||
export interface ReportPdfPayload {
|
||||
docTitle?: string;
|
||||
eventName?: string;
|
||||
eventDate?: string;
|
||||
venue?: string;
|
||||
visitors?: string;
|
||||
safetyManager?: string;
|
||||
safetyPhone?: string;
|
||||
guardCount?: string;
|
||||
fireStation?: string;
|
||||
policeStation?: string;
|
||||
medical?: string;
|
||||
hazardous?: string;
|
||||
}
|
||||
|
||||
/** PDF 생성 결과 — downloadUrl 을 인증 fetch 하여 Blob 다운로드. */
|
||||
export interface GeneratedDoc {
|
||||
documentId: string;
|
||||
fileName: string;
|
||||
downloadUrl: string;
|
||||
}
|
||||
|
||||
const base = (eventId: string) => `/api/events/${encodeURIComponent(eventId)}`;
|
||||
|
||||
export const docsApi = {
|
||||
milestones: (eventId: string) =>
|
||||
api.get<MilestoneRow[]>(`/api/events/${encodeURIComponent(eventId)}/milestones`),
|
||||
documents: (eventId: string) =>
|
||||
api.get<RequiredDocRow[]>(`/api/events/${encodeURIComponent(eventId)}/documents`),
|
||||
review: (eventId: string) =>
|
||||
api.get<DocReview>(`/api/events/${encodeURIComponent(eventId)}/documents/review`),
|
||||
milestones: (eventId: string) => api.get<MilestoneRow[]>(`${base(eventId)}/milestones`),
|
||||
documents: (eventId: string) => api.get<RequiredDocRow[]>(`${base(eventId)}/documents`),
|
||||
review: (eventId: string) => api.get<DocReview>(`${base(eventId)}/documents/review`),
|
||||
transition: (eventId: string, docType: string, action: DocAction) =>
|
||||
api.post<RequiredDocRow>(
|
||||
`/api/events/${encodeURIComponent(eventId)}/documents/${encodeURIComponent(docType)}`,
|
||||
`${base(eventId)}/documents/${encodeURIComponent(docType)}`,
|
||||
{ action },
|
||||
),
|
||||
|
||||
/** 서류(안전관리 계획서) PDF 생성 — 저장 후 다운로드 메타 반환. */
|
||||
generateReportPdf: (eventId: string, docType: string, payload: ReportPdfPayload) =>
|
||||
api.post<GeneratedDoc>(
|
||||
`${base(eventId)}/documents/${encodeURIComponent(docType)}/pdf`,
|
||||
payload,
|
||||
),
|
||||
|
||||
/** 서류·마일스톤 현황 요약 PDF 생성(서버 저장 데이터로 조립). */
|
||||
generateSummaryPdf: (eventId: string) =>
|
||||
api.post<GeneratedDoc>(`${base(eventId)}/document-summary/pdf`, {}),
|
||||
};
|
||||
|
||||
/**
|
||||
* 인증 다운로드 — 공용 client.request 는 JSON 봉투를 강제 파싱하므로 PDF Blob 은 여기서 직접 fetch 한다
|
||||
* (client.ts 무수정 원칙). Authorization 헤더를 부여해 브라우저 다운로드를 트리거한다.
|
||||
*/
|
||||
export async function downloadDoc(doc: GeneratedDoc): Promise<void> {
|
||||
const token = getAccessToken();
|
||||
const res = await fetch(doc.downloadUrl, {
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new ApiRequestError('UNKNOWN', 'PDF 다운로드에 실패했습니다.', res.status);
|
||||
}
|
||||
const blob = await res.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = doc.fileName;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
// 다음 틱에 해제(다운로드 시작 보장).
|
||||
window.setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user