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:
zio 2026-07-14 06:26:52 +09:00
parent 660568f332
commit 23ff7d1799
9 changed files with 599 additions and 28 deletions

View File

@ -46,6 +46,9 @@ dependencies {
// --- ( Postfix SMTP ·EDM· ) --- // --- ( Postfix SMTP ·EDM· ) ---
implementation 'org.springframework.boot:spring-boot-starter-mail' implementation 'org.springframework.boot:spring-boot-starter-mail'
// --- PDF (M6 G-05) HTMLPDF . NanumGothic ( ). ---
implementation 'com.openhtmltopdf:openhtmltopdf-pdfbox:1.0.10'
// --- JWT ( RBAC ) --- // --- JWT ( RBAC ) ---
implementation "io.jsonwebtoken:jjwt-api:${jjwtVersion}" implementation "io.jsonwebtoken:jjwt-api:${jjwtVersion}"
runtimeOnly "io.jsonwebtoken:jjwt-impl:${jjwtVersion}" runtimeOnly "io.jsonwebtoken:jjwt-impl:${jjwtVersion}"

View File

@ -5,8 +5,14 @@ import com.zioinfo.kintex.auth.KintexPrincipal;
import com.zioinfo.kintex.common.ApiResponse; import com.zioinfo.kintex.common.ApiResponse;
import com.zioinfo.kintex.document.dto.DocumentReviewDto; import com.zioinfo.kintex.document.dto.DocumentReviewDto;
import com.zioinfo.kintex.document.dto.DocumentTransitionRequest; 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.MilestoneDto;
import com.zioinfo.kintex.document.dto.ReportPdfRequest;
import com.zioinfo.kintex.document.dto.RequiredDocumentDto; 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.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable; 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.RequestMapping;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
import java.nio.charset.StandardCharsets;
import java.util.List; import java.util.List;
/** /**
* M6 서류·마일스톤 API (SCR-22/23). 행사 RBAC 가드. * M6 서류·마일스톤 API (SCR-22/23). 행사 RBAC 가드.
* HWP/PDF 렌더(파일 ) 이번 스코프 제외 엔드포인트 미노출. * PDF 생성/다운로드는 실구현(G-05). HWP 렌더는 스코프 (후속) 엔드포인트 미노출.
*/ */
@RestController @RestController
@RequestMapping("/api/events/{eventId}") @RequestMapping("/api/events/{eventId}")
public class DocumentController { public class DocumentController {
private final DocumentService service; private final DocumentService service;
private final DocumentPdfService pdfService;
private final EventAccessGuard guard; private final EventAccessGuard guard;
public DocumentController(DocumentService service, EventAccessGuard guard) { public DocumentController(DocumentService service, DocumentPdfService pdfService, EventAccessGuard guard) {
this.service = service; this.service = service;
this.pdfService = pdfService;
this.guard = guard; this.guard = guard;
} }
@ -67,4 +76,43 @@ public class DocumentController {
String action = request == null ? null : request.action(); String action = request == null ? null : request.action();
return ApiResponse.ok(service.transition(eventId, docType, 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);
}
} }

View File

@ -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("&amp;");
case '<' -> out.append("&lt;");
case '>' -> out.append("&gt;");
case '"' -> out.append("&quot;");
case '\'' -> out.append("&#39;");
default -> out.append(c);
}
}
return out.toString();
}
}

View File

@ -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
) {
}

View File

@ -0,0 +1,22 @@
package com.zioinfo.kintex.document.dto;
/**
* SCR-23 신고서류 작성 PDF 생성 요청(M6 G-05). 프론트 웹폼(ReportForm) 1:1 필드 매핑.
* 모든 값은 선택(미입력 서비스가 ""/기본 문구로 대체) 임시저장·미완성 상태에서도 미리보기 PDF 발급 가능.
* 서버는 값들을 A4 안전관리 계획서 서식 HTMLPDF 렌더한다. 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
) {
}

Binary file not shown.

View File

@ -4,7 +4,8 @@
* : (D-150D-0) (·D-·) * : (D-150D-0) (·D-·)
* AI (··kxwp ) + . * AI (··kxwp ) + .
* API (useQuery, // 3) GET /milestones · /documents · /documents/review. * 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 { useEffect, useMemo, useState } from 'react';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
@ -18,8 +19,9 @@ import {
IconTrendUp, IconTrendUp,
IconWarning, IconWarning,
} from '../../components/ui/icons'; } from '../../components/ui/icons';
import { ApiRequestError } from '../../api/client';
import { useResolvedEventId } from '../../hooks/useResolvedEventId'; import { useResolvedEventId } from '../../hooks/useResolvedEventId';
import { docsApi, type RequiredDocRow } from './docsApi'; import { docsApi, downloadDoc, type RequiredDocRow } from './docsApi';
import './docs.css'; import './docs.css';
type DocAction = 'view' | 'hwp' | 'write' | 'fix'; type DocAction = 'view' | 'hwp' | 'write' | 'fix';
@ -31,7 +33,7 @@ const ACTION_LABEL: Record<DocAction, string> = {
fix: '수정', fix: '수정',
}; };
const RENDER_TOOLTIP = 'HWP/PDF 생성은 파일 큐 연동 후속(준비중)'; const HWP_TOOLTIP = 'HWP 생성은 후속 연동(준비중) — 현황 요약은 PDF 로 제공됩니다';
/** 서류 상태 → 리스트 액션 파생. pending 은 액션 없음(준비중 표기). */ /** 서류 상태 → 리스트 액션 파생. pending 은 액션 없음(준비중 표기). */
function actionFor(status: RequiredDocRow['status']): DocAction | undefined { 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 }) { export function DocsMilestonePage({ onOpenAuthoring }: { onOpenAuthoring?: (docType: string) => void }) {
const eventId = useResolvedEventId(); const eventId = useResolvedEventId();
const [toast, setToast] = useState<string | null>(null); 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({ const milestonesQ = useQuery({
queryKey: ['docs-milestones', eventId], queryKey: ['docs-milestones', eventId],
@ -240,10 +260,10 @@ export function DocsMilestonePage({ onOpenAuthoring }: { onOpenAuthoring?: (docT
variant="ai" variant="ai"
block block
leadingIcon={<IconSpark size={16} />} leadingIcon={<IconSpark size={16} />}
disabled onClick={generateSummary}
title={RENDER_TOOLTIP} disabled={summaryBusy}
> >
{summaryBusy ? '문서 생성 중…' : '자동 문서 생성하기 (PDF)'}
</Button> </Button>
</section> </section>
@ -327,7 +347,7 @@ function DocRow({
<Button <Button
variant={btnVariant} variant={btnVariant}
disabled={isRenderAction} disabled={isRenderAction}
title={isRenderAction ? RENDER_TOOLTIP : undefined} title={isRenderAction ? HWP_TOOLTIP : undefined}
onClick={() => (isRenderAction ? undefined : onAction(doc, action))} onClick={() => (isRenderAction ? undefined : onAction(doc, action))}
> >
{ACTION_LABEL[action]} {ACTION_LABEL[action]}

View File

@ -4,7 +4,7 @@
* : ( + AI + ) A4 * : ( + AI + ) A4
* ( ·HWP ·PDF · / kxwp ). * ( ·HWP ·PDF · / kxwp ).
* / API(POST /documents/{docType} save|submit) . * / API(POST /documents/{docType} save|submit) .
* HWP/PDF disabled + . * PDF (POST /documents/{docType}/pdf ). HWP () disabled .
*/ */
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query'; 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 { IconChevronDown, IconDocument, IconSpark, IconWarning } from '../../components/ui/icons';
import { ApiRequestError } from '../../api/client'; import { ApiRequestError } from '../../api/client';
import { useResolvedEventId } from '../../hooks/useResolvedEventId'; import { useResolvedEventId } from '../../hooks/useResolvedEventId';
import { docsApi, type DocAction } from './docsApi'; import { docsApi, downloadDoc, type DocAction, type ReportPdfPayload } from './docsApi';
import './docs.css'; import './docs.css';
const RENDER_TOOLTIP = 'HWP/PDF 생성은 파일 큐 연동 후속(준비중)'; const HWP_TOOLTIP = 'HWP 생성은 후속 연동(준비중) — PDF 를 이용하세요';
/** 서류 표시명 → doc_type 코드(백엔드 required_document.doc_type). 미매핑 시 재해대처계획서. */ /** 서류 표시명 → doc_type 코드(백엔드 required_document.doc_type). 미매핑 시 재해대처계획서. */
const DOC_TYPE_CODE: Record<string, string> = { const DOC_TYPE_CODE: Record<string, string> = {
@ -121,6 +121,7 @@ export function ReportAuthoringPage({ docType = '재해대처계획서' }: { doc
}); });
const [showErrors, setShowErrors] = useState(false); const [showErrors, setShowErrors] = useState(false);
const [toast, setToast] = useState<string | null>(null); const [toast, setToast] = useState<string | null>(null);
const [pdfBusy, setPdfBusy] = useState(false);
useEffect(() => { useEffect(() => {
if (!toast) return; if (!toast) return;
@ -180,6 +181,25 @@ export function ReportAuthoringPage({ docType = '재해대처계획서' }: { doc
transition.mutate('submit'); 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') { if (status === 'loading') {
return ( return (
<div className="kx-page" aria-busy="true"> <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 variant="ghost" onClick={saveDraft} disabled={transition.isPending}>
</Button> </Button>
<Button variant="secondary" disabled title={RENDER_TOOLTIP}> <Button variant="secondary" disabled title={HWP_TOOLTIP}>
HWP HWP
</Button> </Button>
<Button variant="secondary" disabled title={RENDER_TOOLTIP}> <Button variant="secondary" onClick={generatePdf} disabled={pdfBusy}>
PDF {pdfBusy ? 'PDF 생성 중…' : 'PDF 생성'}
</Button> </Button>
<Button onClick={submit} disabled={transition.isPending}> <Button onClick={submit} disabled={transition.isPending}>

View File

@ -2,13 +2,16 @@
* M6 ·(SCR-22/23) API . * M6 ·(SCR-22/23) API .
* (../../api/client) client.ts·endpoints.ts·types.ts . * (../../api/client) client.ts·endpoints.ts·types.ts .
* 정본: 백엔드 DocumentController * 정본: 백엔드 DocumentController
* - GET /api/events/{eventId}/milestones ApiResponse<MilestoneRow[]> * - GET /api/events/{eventId}/milestones ApiResponse<MilestoneRow[]>
* - GET /api/events/{eventId}/documents ApiResponse<RequiredDocRow[]> * - GET /api/events/{eventId}/documents ApiResponse<RequiredDocRow[]>
* - GET /api/events/{eventId}/documents/review ApiResponse<DocReview> * - GET /api/events/{eventId}/documents/review ApiResponse<DocReview>
* - POST /api/events/{eventId}/documents/{docType} ApiResponse<RequiredDocRow> (action: save|submit) * - 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 type MilestoneState = 'done' | 'active' | 'todo';
export interface MilestoneRow { export interface MilestoneRow {
@ -41,16 +44,73 @@ export interface DocReview {
/** save→임시저장(draft), submit→제출(submitted). */ /** save→임시저장(draft), submit→제출(submitted). */
export type DocAction = 'save' | 'submit'; 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 = { export const docsApi = {
milestones: (eventId: string) => milestones: (eventId: string) => api.get<MilestoneRow[]>(`${base(eventId)}/milestones`),
api.get<MilestoneRow[]>(`/api/events/${encodeURIComponent(eventId)}/milestones`), documents: (eventId: string) => api.get<RequiredDocRow[]>(`${base(eventId)}/documents`),
documents: (eventId: string) => review: (eventId: string) => api.get<DocReview>(`${base(eventId)}/documents/review`),
api.get<RequiredDocRow[]>(`/api/events/${encodeURIComponent(eventId)}/documents`),
review: (eventId: string) =>
api.get<DocReview>(`/api/events/${encodeURIComponent(eventId)}/documents/review`),
transition: (eventId: string, docType: string, action: DocAction) => transition: (eventId: string, docType: string, action: DocAction) =>
api.post<RequiredDocRow>( api.post<RequiredDocRow>(
`/api/events/${encodeURIComponent(eventId)}/documents/${encodeURIComponent(docType)}`, `${base(eventId)}/documents/${encodeURIComponent(docType)}`,
{ action }, { 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);
}