한글뷰어 기능수정 Ver.01

This commit is contained in:
koj729
2026-06-19 17:58:47 +09:00
parent 9268e4e6bc
commit 83b6e891ab
49 changed files with 8741 additions and 446 deletions

View File

@@ -0,0 +1,84 @@
# OnlyOffice Document Server 기술 분석 및 도입 타당성 검토
본 문서는 웹 브라우저 환경에서 오피스 문서(Word, Excel, PowerPoint)의 고정밀 뷰잉 및 실시간 편집 기능을 제공하는 오픈소스 솔루션인 **OnlyOffice Document Server**의 개념, 작동 원리, 장단점 및 본 프로젝트로의 도입 타당성을 상세히 비교 분석한 문서입니다.
---
## 1. OnlyOffice Document Server 란?
**OnlyOffice Document Server**는 웹 브라우저 내에서 Microsoft Office 포맷(`.docx`, `.xlsx`, `.pptx`) 문서를 원본과 거의 100% 동일하게 렌더링하고, 다중 사용자 실시간 협업 편집까지 가능하게 지원하는 **엔터프라이즈급 웹 오피스 서버 솔루션**입니다.
* **포지셔닝**: Google Docs, MS Office Online의 설치형 자립(Self-hosted) 대체제.
* **통합성**: Nextcloud, ownCloud, Redmine 등 수많은 그룹웨어 및 문서 관리 시스템(DMS)과 플러그인 형태로 통합되어 사용됩니다.
---
## 2. 핵심 기술 및 렌더링 엔진 작동 원리 (HTML5 Canvas 방식)
기존의 오픈소스 라이브러리(Luckysheet, docx-preview)와 OnlyOffice의 가장 큰 차이점은 **렌더링 방식**에 있습니다.
```
[ Luckysheet / docx-preview ]
엑셀/워드 XML 파싱 ➔ HTML DOM 요소(Table, Div)로 변환 ➔ 브라우저 렌더링
(※ 단점: 브라우저 기본 CSS 영향 및 복잡한 도형/양식 렌더링 한계)
[ OnlyOffice Document Server ]
서버에서 문서 구조 해석 ➔ 클라이언트에 레이아웃 정보 전달 ➔ HTML5 Canvas 픽셀 드로잉
(※ 장점: OS/브라우저 CSS와 무관하게 원본 폰트, 도형, 표 레이아웃을 100% 원본 그대로 표현)
```
* **HTML5 Canvas 렌더링**: 문서를 HTML 태그로 바꾸는 것이 아니라, 문서 한 페이지 한 페이지를 이미지/벡터 그리듯이 픽셀 단위로 Canvas 위에 그려줍니다. 따라서 엑셀의 도형, 화살표, 선, 차트 등 복잡한 개체들이 MS Excel 프로그램에서 볼 때와 똑같이 그려집니다.
* **자체 서식 보존**: OOXML(Office Open XML) 규격을 모태로 개발되어 워드, 엑셀, PPT 파일 포맷과의 호환성이 현존하는 오픈소스 웹 오피스 중 가장 높습니다.
---
## 3. 주요 기능 (Key Features)
1. **고정밀 미리보기 (Viewing)**: 웹 뷰어 모드를 제공하여 폰트 장평, 자간, 표 너비, 삽입된 차트와 도형을 완벽하게 재현합니다.
2. **실시간 협업 편집 (Collaborative Editing)**: 한 문서를 여러 사용자가 브라우저에서 동시에 수정할 수 있는 공동 작업 기능(Co-editing)을 지원합니다.
3. **버전 관리 및 변경 추적**: 문서 내 수정 내역 추적(Track Changes) 및 이전 버전 복원 기능을 제공합니다.
4. **다양한 포맷 지원**: DOCX, XLSX, PPTX뿐만 아니라 ODT, ODS, ODP, TXT, CSV 및 PDF 열람을 통합 지원합니다.
---
## 4. 기존 아키텍처 vs OnlyOffice 비교 분석
| 비교 항목 | 현재 방식 (오픈소스 라이브러리 + PDF 폴백) | OnlyOffice Document Server 도입 방식 |
| :--- | :--- | :--- |
| **렌더링 방식** | 클라이언트 직접 파싱 (Luckysheet, docx-preview 등) + 실패 시 서버 PDF 렌더링 | 전용 OnlyOffice Document Server(독립 서버) 구동 + HTML5 Canvas 드로잉 |
| **도형(Shapes) 지원** | ❌ **직접 뷰잉 시 도형 미지원** (PDF 변환 전환 후 확인 필요) | **도형, 화살표, 차트, 특수 서식 100% 완벽 지원** |
| **편집 기능** | ❌ **조회 전용 (미리보기만 지원)** | **웹 브라우저 내 직접 수정 및 저장 지원 (협업 가능)** |
| **서버 부하** | 낮음 (최초 1회 PDF 변환 시에만 서버 자원 소모) | 높음 (서버 상에 오피스 엔진 인스턴스가 상시 구동되어 메모리 소모량 큼) |
| **시스템 복잡도** | 낮음 (Node.js 서버에 가벼운 CLI 변환기만 배치) | 높음 (OnlyOffice Document Server를 별도 서버/컨테이너로 추가 구축해야 함) |
| **라이선스** | 무료 (오픈소스 + 자체 CLI 툴) | **AGPL v3** (오픈소스 버전은 최대 동시 편집 세션 20개 제한, 초과 시 상용 라이선스 필요) |
---
## 5. OnlyOffice Document Server 도입 연동 흐름
OnlyOffice를 프로젝트에 연동할 때의 기본적인 API 아키텍처 흐름입니다.
```mermaid
sequenceDiagram
autonumber
actor User as 사용자 브라우저
participant Node as PM 백엔드 서버 (Node.js)
participant OO as OnlyOffice Document Server
participant Storage as MinIO 스토리지
User->>Node: 1. 문서 미리보기 요청
Node->>Node: 2. OnlyOffice 전용 연동 토큰(JWT) 및 문서 설정 JSON 생성
Node-->>User: 3. OnlyOffice Editor HTML/JS (설정값 포함) 반환
User->>OO: 4. 브라우저가 OnlyOffice JS 로드 및 문서 세션 요청
OO->>Storage: 5. MinIO로부터 원본 오피스 파일 다운로드
OO->>User: 6. Canvas 렌더링을 통한 고해상도 오피스 뷰어 화면 표출
```
### 필수 연동 API 개발 필요 사항 (Node.js 백엔드 구현)
1. **Document Config Generator**: OnlyOffice Editor가 구동되기 위해 필요한 파일 고유 키, 다운로드 URL, 유저 권한 정보 등을 담은 JSON 객체를 반환하는 API.
2. **Callback Handler API**: 사용자가 웹 오피스에서 문서를 수정하고 닫았을 때, OnlyOffice Server가 편집 완료된 파일을 백엔드로 전송(HTTP POST)해 주는데, 이를 수신하여 MinIO 및 DB에 갱신 저장하는 Callback API 개발 필요.
---
## 6. 결론 및 제안
* **도입 권장 경우**:
만약 프로젝트의 요구사항이 단순 "문서 미리보기"를 넘어 **"웹 브라우저 상에서 엑셀/워드 파일을 직접 수정하고 협업 편집하여 저장하는 기능"**까지 포함해야 한다면, OnlyOffice Document Server의 도입은 **필수적**입니다. 현존하는 오픈소스 대안 중 최고의 호환성을 자랑합니다.
* **현재 프로젝트 기준의 조언**:
단순 미리보기 조회 환경에서는 OnlyOffice 구동을 위한 메모리(최소 4GB RAM 이상 독립 컨테이너 필요) 및 동시성 라이선스 제약(동시 접속 20대 이상 시 유료 라이선스 도입 필요)이 부담될 수 있습니다.
따라서, **조회 중심 서비스인 현재 구조에서는 기존의 `Luckysheet/docx-preview + PDF 상시 전환` 방식이 리소스 대비 최선의 효율**이며, 향후 **웹 오피스 직접 편집 기능**이 비즈니스 요구사항으로 추가될 때 OnlyOffice 도입을 검토하는 아키텍처 로드맵이 가장 합리적입니다.

View File

@@ -0,0 +1,118 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>관리자 화면 기능 설계 명세표</title>
<style>
body {
font-family: 'Malgun Gothic', '맑은 고딕', sans-serif;
background-color: #f1f5f9;
padding: 20px;
}
table {
width: 100%;
border-collapse: collapse;
background-color: #ffffff;
box-shadow: 0 4px 6px rgba(0,0,0,0.05);
border-radius: 8px;
overflow: hidden;
}
th {
background-color: #0f172a;
color: #ffffff;
font-weight: bold;
padding: 12px;
text-align: left;
border: 1px solid #cbd5e1;
}
td {
padding: 12px;
border: 1px solid #cbd5e1;
color: #334155;
}
tr:nth-child(even) {
background-color: #f8fafc;
}
</style>
</head>
<body>
<h2>🖥️ 관리자 화면(Admin Panel) 필수 기능 설계 명세표</h2>
<p>※ 이 파일을 Excel에서 <strong>[파일] -> [열기]</strong>로 실행하거나, 표 전체를 복사하여 Excel 시트에 붙여넣으시면 서식과 셀 구조가 그대로 유지되어 열립니다.</p>
<table>
<thead>
<tr>
<th style="width: 15%;">대분류</th>
<th style="width: 20%;">메뉴명</th>
<th style="width: 35%;">상세 기능 명세</th>
<th style="width: 30%;">추가 고려사항 (고급 기능)</th>
</tr>
</thead>
<tbody>
<tr>
<td><strong>프로젝트 관리</strong></td>
<td>프로젝트 등록 및 설정</td>
<td>신규 현장/공구의 생성 및 수정, 프로젝트별 용량 제한(storage_byte) 설정 및 잠금 토글(is_active) 관리</td>
<td>비공개/보안(Secret) 폴더에 대한 일반 사용자의 접근 신청 결재 및 임시 승인 처리</td>
</tr>
<tr>
<td><strong>프로젝트 관리</strong></td>
<td>실시간 배너 공지</td>
<td>대시보드 상단 마르퀴 띠배너 공지사항(banner_notice) 등록 및 소켓 기반 실시간 송출 제어</td>
<td>배너 공지 예약 노출 설정 및 노출/송출 이력 관리</td>
</tr>
<tr>
<td><strong>사용자 권한 관리</strong></td>
<td>사용자 계정 제어</td>
<td>사용자 계정 등록, 정보(부서/직급) 수정, 비밀번호 재설정 및 퇴사자 계정 잠금(is_resigned) 처리</td>
<td>외부 SSO(Sentinel) 계정 연동 및 중복 로그인 차단 옵션 관리</td>
</tr>
<tr>
<td><strong>사용자 권한 관리</strong></td>
<td>프로젝트 권한 배정</td>
<td>현장별 참여 유저 목록 배정 및 권한 등급(Master/Sub-Master/Worker/Viewer) 마우스 클릭 지정</td>
<td>폴더 수준의 세부 접근 제어 리스트(ACL) 추가 연동</td>
</tr>
<tr>
<td><strong>서버 및 리소스 모니터링</strong></td>
<td>용량 분석 현황</td>
<td>현장별 누적 스토리지 사용량, 남은 용량 및 파일/폴더 개수 대시보드 시각화</td>
<td>스토리지 임계값(예: 90%) 도달 시 관리자 자동 경고 알림</td>
</tr>
<tr>
<td><strong>서버 및 리소스 모니터링</strong></td>
<td>압축 다운로드 관리</td>
<td>비동기 폴더 압축 다운로드(BullMQ) 작업의 진행 현황 모니터링 및 대기열 제어</td>
<td>임시 보존 기간이 지난 압축 임시파일 자동/일괄 영구 삭제를 통한 디스크 확보</td>
</tr>
<tr>
<td><strong>시스템 감사 및 통계</strong></td>
<td>감사 로그 조회</td>
<td>파일 삭제, 이동, 다운로드 등 민감한 조작 행위(tb_log)의 날짜/유저/활동별 필터 검색</td>
<td>감사 로그 이력 보고서 인쇄 및 엑셀 백업 다운로드</td>
</tr>
<tr>
<td><strong>시스템 감사 및 통계</strong></td>
<td>행동 분석 및 통계</td>
<td>사용자 UI 클릭 로그(tb_click_log) 기반 최다 접근 메뉴 및 최다 조회 도면/문서 사용 현황 분석</td>
<td>AI 요약 서비스(Gemini API) 호출 제한량(Quota) 관리 및 비용 통제</td>
</tr>
<tr>
<td><strong>실시간 접속자</strong></td>
<td>실시간 접속 현황</td>
<td>현재 소켓 연결된 동시 접속 유저 목록 및 접속 IP와 현재 탐색 중인 아카이브 경로 실시간 표출</td>
<td>특정 유저 강제 소켓 연결 끊기(Kick) 기능</td>
</tr>
<tr>
<td><strong>시스템 환경 및 정책 관리</strong></td>
<td>보관 및 삭제 정책 설정</td>
<td>기존 pageRenderer.js에 하드코딩된 '최소 유지 파일 개수(3개)' 및 '보존 기간(15일)' 임계치를 DB화하여, 관리자 화면에서 프로젝트별/글로벌 동적 설정 기능 제공</td>
<td>tb_project 테이블에 limit_file_count 및 limit_days 컬럼을 연동하여 프론트엔드가 변경된 기준값으로 실시간 바인딩되도록 API 구조 개선</td>
</tr>
</tbody>
</table>
</body>
</html>

View File

@@ -0,0 +1,343 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>PROJECT MASTER ver 4.0 기능명세서</title>
<!-- Google Fonts - Inter & Noto Sans KR -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;600;700&family=Noto+Sans+KR:wght@300;400;500;700&display=swap" rel="stylesheet">
<style>
:root {
--primary: #10b981;
--primary-dark: #047857;
--primary-light: #ecfdf5;
--bg: #f8fafc;
--card-bg: #ffffff;
--text-main: #0f172a;
--text-muted: #475569;
--border: #e2e8f0;
--shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
--shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);
}
body {
font-family: 'Inter', 'Noto Sans KR', sans-serif;
background-color: var(--bg);
color: var(--text-main);
line-height: 1.7;
margin: 0;
padding: 0;
}
.container {
max-width: 1000px;
margin: 40px auto;
padding: 40px;
background-color: var(--card-bg);
border-radius: 16px;
box-shadow: var(--shadow-lg);
border: 1px solid var(--border);
}
/* Header Styling */
header {
border-bottom: 2px solid var(--border);
padding-bottom: 24px;
margin-bottom: 40px;
position: relative;
}
h1 {
font-size: 2.2rem;
font-weight: 700;
margin: 0 0 8px 0;
background: linear-gradient(135deg, var(--primary-dark) 0%, var(--primary) 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
.subtitle {
font-size: 1.1rem;
color: var(--text-muted);
margin: 0;
}
/* Typography & Structure */
h2 {
font-size: 1.6rem;
font-weight: 700;
color: var(--primary-dark);
margin-top: 40px;
margin-bottom: 20px;
display: flex;
align-items: center;
border-left: 5px solid var(--primary);
padding-left: 12px;
}
h3 {
font-size: 1.25rem;
font-weight: 600;
color: var(--text-main);
margin-top: 30px;
margin-bottom: 12px;
border-bottom: 1px dashed var(--border);
padding-bottom: 6px;
}
p, li {
font-size: 1rem;
color: var(--text-muted);
}
ul {
padding-left: 20px;
margin-bottom: 20px;
}
li {
margin-bottom: 8px;
}
/* Premium Table Styles */
table {
width: 100%;
border-collapse: collapse;
margin: 24px 0;
font-size: 0.95rem;
box-shadow: var(--shadow);
border-radius: 8px;
overflow: hidden;
border: 1px solid var(--border);
}
th {
background-color: var(--primary-dark);
color: #ffffff;
font-weight: 600;
text-align: left;
padding: 14px 16px;
}
td {
padding: 14px 16px;
border-bottom: 1px solid var(--border);
background-color: #ffffff;
color: var(--text-muted);
}
tr:last-child td {
border-bottom: none;
}
tr:nth-child(even) td {
background-color: #f8fafc;
}
tr:hover td {
background-color: var(--primary-light);
color: var(--primary-dark);
transition: all 0.2s ease;
}
/* Mermaid Wrapper */
.diagram-container {
background-color: #f8fafc;
border: 1px solid var(--border);
border-radius: 12px;
padding: 24px;
margin: 24px 0;
display: flex;
justify-content: center;
box-shadow: inset 0 2px 4px 0 rgb(0 0 0 / 0.05);
}
.mermaid {
width: 100%;
display: flex;
justify-content: center;
}
/* Highlight Boxes (Alerts) */
.alert {
background-color: var(--primary-light);
border-left: 4px solid var(--primary);
border-radius: 4px 8px 8px 4px;
padding: 16px 20px;
margin: 24px 0;
color: var(--primary-dark);
}
.alert-title {
font-weight: 700;
margin-bottom: 4px;
}
/* Responsive Design */
@media (max-width: 768px) {
.container {
margin: 0;
padding: 20px;
border-radius: 0;
box-shadow: none;
border: none;
}
h1 {
font-size: 1.8rem;
}
table {
display: block;
overflow-x: auto;
}
}
</style>
<!-- Mermaid.js CDN for dynamic rendering of charts -->
<script src="https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.min.js"></script>
<script>
mermaid.initialize({
startOnLoad: true,
theme: 'neutral',
securityLevel: 'loose'
});
</script>
</head>
<body>
<div class="container">
<header>
<h1>PROJECT MASTER ver 4.0 기능명세서</h1>
<p class="subtitle">시스템 아키텍처, 기능 명세 및 운영 가이드라인</p>
</header>
<h2>1. 시스템 아키텍처 및 연동 구조</h2>
<p>본 시스템은 실시간 협업과 대용량 건설 산출물 관리를 위해 웹소켓, 비동기 작업 큐 및 분산 저장소를 포함하는 모던 웹 아키텍처로 구성되어 있습니다.</p>
<div class="diagram-container">
<div class="mermaid">
graph TD
Client[Browser / Client] <-->|HTTP / Axios| NodeServer[Node.js Express Server]
Client <-->|Websocket / Socket.io| NodeServer
NodeServer <-->|SQL Queries| Postgres[(PostgreSQL DB)]
NodeServer <-->|PubSub / Queue| Redis[(Redis Server)]
NodeServer <-->|Presigned URL / PUT| MinIO[(MinIO Object Storage)]
</div>
</div>
<h3>기술 스택 (Tech Stack)</h3>
<ul>
<li><strong>프론트엔드 (Frontend)</strong>: HTML5, Vanilla JavaScript, Vanilla CSS, Axios, Socket.io-client, OpenLayers (GIS), Cesium (3D 모델)</li>
<li><strong>백엔드 (Backend)</strong>: Node.js (Express), Socket.io, BullMQ (비동기 작업 큐), Winston (로깅)</li>
<li><strong>데이터베이스 (Database)</strong>: PostgreSQL (ver4 스키마), Redis (소켓 및 작업 큐 세션 관리)</li>
<li><strong>오브젝트 스토리지 (Storage)</strong>: MinIO / AWS S3 (Presigned URL 및 원격 정적 파일 보관)</li>
</ul>
<h2>2. 사용자 권한 체계 (User Permission System)</h2>
<p>사용자 정보 및 소속 프로젝트별로 세분화된 권한 관리를 지원하며, UI 및 API 호출 수준에서 차단 및 필터링이 적용됩니다.</p>
<table>
<thead>
<tr>
<th>권한 레벨</th>
<th>그룹/명칭</th>
<th>주요 권한 범위 및 설명</th>
</tr>
</thead>
<tbody>
<tr>
<td><strong>Super / Dev</strong></td>
<td>super / dev</td>
<td>시스템 전체 관리 및 개발자 전용 모달 접근 권한. 비활성화된 프로젝트 우회 접근 지원.</td>
</tr>
<tr>
<td><strong>Master</strong></td>
<td>관리자</td>
<td>프로젝트 전반의 산출물 수정/삭제/관리, 개별 폴더/파일별 권한 제어 및 대량 압축 다운로드 권한.</td>
</tr>
<tr>
<td><strong>Sub-Master</strong></td>
<td>부관리자</td>
<td>신규 폴더 생성 및 삭제, 과업개요 정보 수정, 사용자 권한 배정 및 변경 처리.</td>
</tr>
<tr>
<td><strong>Security Worker</strong></td>
<td>보안참여자</td>
<td>보안 설정이 적용된 비밀 폴더 및 파일에 대한 읽기/쓰기 권한 부여 및 관리.</td>
</tr>
<tr>
<td><strong>Worker</strong></td>
<td>일반참여자</td>
<td>일반 산출물 업로드/다운로드, 개인 메모 작성 및 수정, AI 연동 문서 요약(Gemini) 기능 활용.</td>
</tr>
<tr>
<td><strong>Viewer</strong></td>
<td>참관자</td>
<td>스토리지 조회 및 개별 파일 다운로드만 가능 (활동 로그 노출 방지 및 파일 추가/수정 버튼 숨김).</td>
</tr>
</tbody>
</table>
<h2>3. 핵심 모듈 명세 (Core Module Specifications)</h2>
<h3>3.1 아카이브 모듈 (Archive Module)</h3>
<p>프로젝트별 모든 산출물(도면, 공문, 과업 문서 등)을 관리하는 핵심 가상 스토리지 시스템입니다.</p>
<ul>
<li><strong>폴더 구조 제어</strong>: 폴더 생성(createFolder), 이름 변경(renameTarget), 다른 폴더로의 계층식 위치 이동(relocateTarget)을 실시간 지원합니다.</li>
<li><strong>S3 Presigned URL 업로드</strong>: 대용량 업로드 안전성을 위해 서버의 자원을 거치지 않고, 스토리지로부터 Presigned URL을 직접 발급받아 브라우저가 직접 Put Object를 수행합니다.</li>
<li><strong>백그라운드 ZIP 압축 다운로드</strong>: 대량의 파일이나 폴더 통째 다운로드 시, BullMQ 큐에 작업을 위임하여 서버 부하를 최소화하고 완료 시 다운로드 링크를 팝업으로 제공합니다.</li>
<li><strong>가상 휴지통</strong>: 삭제된 파일은 즉시 지워지지 않고 휴지통으로 이동하며, 소유자 및 관리자의 승인을 통해 복원하거나 영구 삭제할 수 있습니다.</li>
<li><strong>뷰어 및 메모 연동</strong>: HWP/DWG 등 다양한 확장자 파일을 PDF로 자동 변환하여 브라우저에서 즉시 열람하는 통합 뷰어를 제공하며, 파일별 텍스트 메모 및 AI 요약 서비스를 연동합니다.</li>
</ul>
<h3>3.2 과업개요 모듈 (Overview Module)</h3>
<p>계약 사항 및 상세 마일스톤 정보, 지도 개요도를 하나의 통합 카드뷰 대시보드로 제공합니다.</p>
<ul>
<li><strong>계약 및 사업 정보</strong>: 계약 금액(외화 금액 포함), Commencement Date, 준공 예정일, 공동 수급사 구성 지분율 및 대표인 정보를 보관합니다.</li>
<li><strong>개요도 드래그앤드롭 업로드</strong>: 현장 위치도 및 종합 개요도를 별도 파일 선택창 없이 드래그하여 직관적으로 변경 및 등록할 수 있습니다.</li>
<li><strong>과업 일정 캘린더</strong>: 연월 단위의 마일스톤 일정을 생성/관리하고 주요 진척도를 실시간 모니터링합니다.</li>
<li><strong>시차 연동 실시간 시계</strong>: 해외 프로젝트의 경우 해당 현장 국가의 표준시 시차 데이터를 바탕으로 실시간 현지 시각을 헤더에 동적으로 출력합니다.</li>
</ul>
<h3>3.3 공문 모듈 (Official Document Module)</h3>
<p>프로젝트 관계 기관 간 송수신된 공문 번호, 날짜, 수신처 목록과 문서를 체계적으로 매핑 및 보관합니다.</p>
<ul>
<li>공문 정보 관리 (발송일, 제목, 수발신처, 문서 번호).</li>
<li>공문 매핑 첨부파일 연계 보관 및 1-클릭 다운로드 서비스.</li>
</ul>
<h3>3.4 GSIM (GIS 기반 위치 모델 모듈)</h3>
<p>건설 현장의 실제 위경도 좌표 및 고도(Height) 값과 연계하여 모델 및 데이터를 공간 정보와 매핑합니다.</p>
<ul>
<li>2D(OpenLayers) 지도 및 3D(Cesium) 입체 모델 뷰어 탑재.</li>
<li>줌 레벨에 최적화된 마커 클러스터링(Clustering) 기법으로 대량의 포인트를 성능 저하 없이 표시.</li>
</ul>
<h2>4. 실시간 동기화 및 로그 모듈</h2>
<h3>4.1 Websocket 다중 접속자 커서 (Socket.io)</h3>
<p>프로젝트 협업 효율성을 극대화하기 위해 다중 유저 동시 접속 시 각 유저의 Client 마우스 움직임을 추적하여 화면상에 실시간으로 커서 위치와 소속/이름표를 그려줍니다.</p>
<h3>4.2 실시간 로그 및 클릭 통계</h3>
<ul>
<li><strong>푸터 실시간 한 줄 로그</strong>: 파일 생성, 삭제, 변환 등 유저가 수행한 핵심 행위가 푸터의 띠 배너 형태로 전 유저에게 실시간 브로드캐스트됩니다.</li>
<li><strong>클릭 활동 추적</strong>: 향후 화면 분석 및 건설 관리 통계용으로 유저가 누르는 주요 탭, 버튼 등의 액션을 수집하여 <code>tb_click_log</code> 테이블에 실시간으로 기록합니다.</li>
</ul>
<h2>5. 안정성 및 장애 예방 설계 (Reliability & Robustness)</h2>
<div class="alert">
<div class="alert-title">💡 최신 패치 반영 사항 (System Resilience)</div>
<ul>
<li><strong>캐시 잠김 해제 미들웨어</strong>: GET 요청 중 정적 자원을 제외한 비동기 동적 API 요청에 대하여 브라우저 304 캐시를 완전히 강제 무효화함으로써, 이전 에러 데이터에 의한 화면 중단 현상을 원천 방지하였습니다.</li>
<li><strong>서버 Crash 방어 널가드 (Null-Guard)</strong>: DB 조회 쿼리 등에서 일시적인 커넥션 유실이나 스키마 에러가 나더라도 백엔드 프로세스가 통째로 죽지 않도록, 주요 API 핸들러 전반에 예외 위임(try-catch) 및 Null 체크 조건문을 완비하였습니다.</li>
<li><strong>클라이언트 실시간 디버거</strong>: 사용자 브라우저 상에서 유발된 자바스크립트 크래시 및 미처리 거부(Unhandled Rejection) 오류를 백엔드 콘솔로 자동 수집/출력해 주는 에러 추적 스크립트를 내장하였습니다.</li>
</ul>
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,377 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>PM_ver4 통합 관리자 메뉴정의서</title>
<!-- Google Fonts - Inter & Noto Sans KR -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=Noto+Sans+KR:wght@300;400;500;700&display=swap"
rel="stylesheet">
<style>
:root {
--primary: #1e5149;
/* Forest Green */
--primary-dark: #142e29;
--primary-light: #e9eeed;
--border: #d2dcdb;
--bg: #f4f7f6;
--card-bg: #ffffff;
--text-main: #1f2937;
--text-muted: #4b746d;
--shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.05), 0 2px 4px -2px rgba(0, 0, 0, 0.05);
}
body {
font-family: 'Inter', 'Noto Sans KR', sans-serif;
background-color: var(--bg);
color: var(--text-main);
line-height: 1.6;
margin: 0;
padding: 40px 20px;
}
.container {
max-width: 1200px;
margin: 0 auto;
background-color: var(--card-bg);
border-radius: 12px;
padding: 40px;
box-shadow: var(--shadow);
border: 1px solid var(--border);
}
header {
border-bottom: 2px solid var(--border);
padding-bottom: 20px;
margin-bottom: 30px;
}
h1 {
font-size: 2rem;
font-weight: 700;
color: var(--primary-dark);
margin: 0 0 8px 0;
}
.subtitle {
font-size: 1.05rem;
color: var(--text-muted);
margin: 0;
}
/* Filter Controls */
.filter-controls {
display: flex;
gap: 10px;
margin-bottom: 24px;
flex-wrap: wrap;
}
.filter-btn {
background-color: #ffffff;
border: 1px solid var(--border);
color: var(--text-main);
padding: 8px 16px;
border-radius: 20px;
font-size: 0.85rem;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
}
.filter-btn:hover,
.filter-btn.active {
background-color: var(--primary);
color: #ffffff;
border-color: var(--primary);
}
/* Menu Definition Table */
table {
width: 100%;
border-collapse: collapse;
font-size: 0.88rem;
margin-top: 10px;
border-radius: 8px;
overflow: hidden;
border: 1px solid var(--border);
}
th {
background-color: var(--primary-dark);
color: #ffffff;
font-weight: 600;
text-align: left;
padding: 12px 16px;
font-size: 0.9rem;
}
td {
padding: 14px 16px;
border-bottom: 1px solid var(--border);
vertical-align: top;
color: var(--text-main);
}
tr:last-child td {
border-bottom: none;
}
tr:nth-child(even) td {
background-color: #f9fafb;
}
tr.hidden {
display: none;
}
/* Badges */
.badge-cat {
display: inline-block;
background-color: var(--primary-light);
color: var(--primary);
padding: 2px 8px;
border-radius: 4px;
font-size: 0.75rem;
font-weight: 700;
}
.badge-table {
font-family: monospace;
background-color: #f1f5f9;
color: #334155;
padding: 2px 6px;
border-radius: 4px;
font-size: 0.8rem;
border: 1px solid #e2e8f0;
display: inline-block;
margin-bottom: 4px;
}
.menu-id {
font-family: monospace;
font-weight: 600;
color: #0369a1;
font-size: 0.8rem;
}
/* Rules list style */
.rules-list {
margin: 0;
padding-left: 16px;
font-size: 0.82rem;
color: #4b5563;
}
.rules-list li {
margin-bottom: 4px;
}
</style>
</head>
<body>
<div class="container">
<header>
<h1>PM_ver4 통합 관리자 메뉴정의서</h1>
<p class="subtitle">관리자 화면(Admin Panel) 1단계 및 2단계 메뉴 기능 명세 및 테이블 매핑 테이블</p>
</header>
<!-- Filter Buttons -->
<div class="filter-controls">
<button class="filter-btn active" onclick="filterMenu('all')">전체보기</button>
<button class="filter-btn" onclick="filterMenu('dashboard')">1. Dashboards</button>
<button class="filter-btn" onclick="filterMenu('project')">2. 프로젝트 관리</button>
<button class="filter-btn" onclick="filterMenu('user')">3. 사용자 관리</button>
<button class="filter-btn" onclick="filterMenu('system')">4. 시스템 감사 및 환경</button>
</div>
<!-- Menu Table -->
<table>
<thead>
<tr>
<th style="width: 15%;">1단계 메뉴</th>
<th style="width: 15%;">2단계 메뉴</th>
<th style="width: 15%;">메뉴 ID / Hash</th>
<th style="width: 25%;">주요 기능 명세 (Functional Specifications)</th>
<th style="width: 15%;">관련 DB 테이블</th>
<th style="width: 15%;">비고 및 비즈니스 규칙</th>
</tr>
</thead>
<tbody id="menu-tbody">
<!-- Row 1 -->
<tr data-category="dashboard">
<td><strong>1. Dashboards</strong></td>
<td><span class="badge-cat">종합 용량 및 접속자</span></td>
<td class="menu-id">menu-dashboard<br>#dashboard</td>
<td>
• 전체 디스크 용량, 실시간 소켓 접속자 수, Redis 대기 작업 수 요약 노출.<br>
• 현장별 사용 용량, 백분율, 파일 수량 프로그레스 바 표시.<br>
• 실시간 소켓 연결 정보 그리드 및 사용자 강제퇴장(Kick) 기능.
</td>
<td>
<span class="badge-table">tb_project</span><br>
<span class="badge-table">tb_data</span><br>
<span class="badge-table">Redis (Queue)</span>
</td>
<td>소켓 세션 맵과 실시간 동기화하여 퇴장 처리 즉시 실행.</td>
</tr>
<!-- Row 2 -->
<tr data-category="project">
<td><strong>2. 프로젝트 관리</strong></td>
<td><span class="badge-cat">프로젝트 관리</span></td>
<td class="menu-id">menu-project-mgmt<br>#project-mgmt</td>
<td>
• 프로젝트 목록 조회 및 신규 등록/수정/삭제 모달 팝업.<br>
• 프로젝트 구분 카테고리 지정.<br>
• 행 클릭 시 우측에 참여 사용자 등급 조회 및 즉각 수정/배정제외.<br>
• 미배정 사용자 다중 선택 일괄 추가 배정 팝업.
</td>
<td>
<span class="badge-table">tb_project</span><br>
<span class="badge-table">code_detail</span><br>
<span class="badge-table">tb_permission</span><br>
<span class="badge-table">tb_user</span>
</td>
<td>
<ul class="rules-list">
<li>등록 시 ID 직접 입력, 수정 시 ID 비활성.</li>
<li><strong>삭제 제한</strong>: 관련 테이블(tb_data, tb_official_doc_file, tb_banner_notice 등)에 현장 ID
사용 이력이 있으면 삭제 불가능.</li>
</ul>
</td>
</tr>
<!-- Row 3 -->
<tr data-category="project">
<td><strong>2. 프로젝트 관리</strong></td>
<td><span class="badge-cat">실시간 배너 공지</span></td>
<td class="menu-id">menu-banner-notice<br>#banner-notice</td>
<td>
• 배너 공지사항 작성 및 등록 (대상 현장, 송출 기간, 편집용 등록일 지정).<br>
• 송출 상태(송출중, 예약됨, 만료) 및 등록일 범위 필터 검색 기능.<br>
• 진행 중인 공지 개별 즉시 송출중지 처리.
</td>
<td>
<span class="badge-table">tb_banner_notice</span><br>
<span class="badge-table">code_detail</span><br>
<span class="badge-table">tb_project</span>
</td>
<td>시작일/종료일 경과 여부에 따라 송출 상태 실시간 계산 렌더링.</td>
</tr>
<!-- Row 4 -->
<tr data-category="user">
<td><strong>3. 사용자 관리</strong></td>
<td><span class="badge-cat">사용자 관리</span></td>
<td class="menu-id">menu-user-mgmt<br>#user-mgmt</td>
<td>
• 전체 사용자 정보 조회 및 신규 계정 등록/정보 수정/삭제 모달.<br>
• 권한 그룹 및 재직/퇴직잠금 상태 배지 설정.<br>
• 행 클릭 시 해당 유저의 프로젝트 참여 목록(권한 등급 포함) 우측 연동 리스트업.
</td>
<td>
<span class="badge-table">tb_user</span><br>
<span class="badge-table">code_detail</span><br>
<span class="badge-table">tb_permission</span><br>
<span class="badge-table">tb_project</span>
</td>
<td><strong>삭제 제한</strong>: 권한 테이블(tb_permission)에 프로젝트 참여 권한 정보가 존재하면 계정 삭제 불가능.</td>
</tr>
<!-- Row 5 -->
<tr data-category="system">
<td><strong>4. 시스템 감사 및 환경</strong></td>
<td><span class="badge-cat">감사 로그 조회</span></td>
<td class="menu-id">menu-audit-logs<br>#audit-logs</td>
<td>
• 파일 삭제, 이동, 다운로드 등 보안 감사 대상 활동 조회.<br>
• 유저 ID 검색 및 액션 타입(activity) 필터 검색 기능.
</td>
<td>
<span class="badge-table">tb_log</span>
</td>
<td>감사 추적용 조회 전용 화면.</td>
</tr>
<!-- Row 6 -->
<tr data-category="system">
<td><strong>4. 시스템 감사 및 환경</strong></td>
<td><span class="badge-cat">보관 및 삭제 정책 설정</span></td>
<td class="menu-id">menu-delete-policy<br>#delete-policy</td>
<td>
• 시스템 공통 자동 보존 및 파일 삭제 임계 기준 설정 폼.<br>
• 폼 데이터 변경 시 작동 시나리오 문구 동적 요약 안내.<br>
• 정기 자동 삭제 스케줄러 배치 구동 이력 로그 리스트업.
</td>
<td>
<span class="badge-table">tb_system_policy</span><br>
<span class="badge-table">tb_auto_clean_log</span>
</td>
<td>
<ul class="rules-list">
<li>설정값 변경 저장 시 로그 이력의 대상에는 'SYSTEM' 기입.</li>
<li>배치는 수동 변경 값을 즉각 바인딩하여 다음 기동 시 적용.</li>
</ul>
</td>
</tr>
<!-- Row 7 -->
<tr data-category="system">
<td><strong>4. 시스템 감사 및 환경</strong></td>
<td><span class="badge-cat">공통 코드 관리</span></td>
<td class="menu-id">menu-code-mgmt<br>#code-mgmt</td>
<td>
• 대분류 코드 마스터 등록/수정/삭제 모달.<br>
• 대분류 선택 시 하단에 소속 소분류 세부 코드 실시간 필터 로드.<br>
• 소분류 코드 등록/수정/삭제 모달.<br>
• 소분류 base_code (대분류_소분류 결합 코드) 자동 완성 저장.
</td>
<td>
<span class="badge-table">code_master</span><br>
<span class="badge-table">code_detail</span>
</td>
<td>
<ul class="rules-list">
<li>대분류 미선택 시 하단 세부코드 등록 차단 및 경고.</li>
<li><strong>삭제 제한</strong>: 대분류 코드에 속한 하위 소분류 세부 코드(code_detail)가 존재하면 대분류 삭제 불가능.</li>
</ul>
</td>
</tr>
</tbody>
</table>
</div>
<!-- Interactive Filtering Script -->
<script>
function filterMenu(category) {
// Update active filter button styling
const buttons = document.querySelectorAll('.filter-btn');
buttons.forEach(btn => btn.classList.remove('active'));
// Highlight clicked button
const clickedBtn = event.currentTarget || event.target;
if (clickedBtn && clickedBtn.classList) {
clickedBtn.classList.add('active');
}
// Show/Hide rows
const rows = document.querySelectorAll('#menu-tbody tr');
rows.forEach(row => {
if (category === 'all' || row.getAttribute('data-category') === category) {
row.classList.remove('hidden');
} else {
row.classList.add('hidden');
}
});
}
</script>
</body>
</html>

View File

@@ -0,0 +1,424 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>PM 프로젝트 워크플로우 및 데이터 흐름도</title>
<!-- Google Fonts - Inter & Noto Sans KR -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;600;700&family=Noto+Sans+KR:wght@300;400;500;700&display=swap"
rel="stylesheet">
<!-- Mermaid.js 라이브러리 (CDN) -->
<script src="https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.min.js"></script>
<script>
mermaid.initialize({
startOnLoad: true,
theme: 'default',
securityLevel: 'loose',
themeVariables: {
fontFamily: 'Inter, Noto Sans KR, sans-serif',
primaryColor: '#f43f5e',
primaryTextColor: '#fff',
lineColor: '#cbd5e1'
}
});
</script>
<style>
:root {
--primary: #f43f5e;
--primary-dark: #be123c;
--primary-light: #fff1f2;
--bg: #f8fafc;
--card-bg: #ffffff;
--text-main: #0f172a;
--text-muted: #475569;
--border: #e2e8f0;
--shadow: 0 4px 6px -1px rgb(0 0 0 / 0.05), 0 2px 4px -2px rgb(0 0 0 / 0.05);
--shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.05), 0 4px 6px -4px rgb(0 0 0 / 0.05);
}
body {
font-family: 'Inter', 'Noto Sans KR', sans-serif;
background-color: var(--bg);
color: var(--text-main);
line-height: 1.7;
margin: 0;
padding: 0;
}
.container {
max-width: 1200px;
margin: 40px auto;
padding: 40px;
background-color: var(--card-bg);
border-radius: 16px;
box-shadow: var(--shadow-lg);
border: 1px solid var(--border);
}
/* Header Styling */
header {
border-bottom: 2px solid var(--border);
padding-bottom: 24px;
margin-bottom: 30px;
}
h1 {
font-size: 2.2rem;
font-weight: 700;
margin: 0 0 8px 0;
background: linear-gradient(135deg, var(--primary-dark) 0%, var(--primary) 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
.subtitle {
font-size: 1.1rem;
color: var(--text-muted);
margin: 0;
}
h2 {
font-size: 1.5rem;
color: #1e293b;
border-left: 5px solid var(--primary);
padding-left: 12px;
margin-top: 40px;
margin-bottom: 20px;
font-weight: 700;
}
h3 {
font-size: 1.2rem;
color: #334155;
margin-top: 30px;
margin-bottom: 15px;
font-weight: 600;
}
p,
li {
color: var(--text-muted);
font-size: 1rem;
}
ul {
padding-left: 20px;
}
li {
margin-bottom: 8px;
}
/* Card / Diagram Area Style */
.diagram-card {
background: #ffffff;
border: 1px solid var(--border);
border-radius: 12px;
padding: 24px;
margin: 20px 0 35px 0;
box-shadow: var(--shadow);
overflow-x: auto;
}
.mermaid {
display: flex;
justify-content: center;
align-items: center;
margin: 0 auto;
}
/* Highlight text */
code {
font-family: monospace;
background-color: #f1f5f9;
color: #e11d48;
padding: 2px 6px;
border-radius: 4px;
font-size: 0.9rem;
}
.badge {
display: inline-block;
background-color: var(--primary-light);
color: var(--primary-dark);
padding: 4px 10px;
border-radius: 20px;
font-size: 0.85rem;
font-weight: 600;
margin-bottom: 10px;
}
/* Responsive */
@media (max-width: 768px) {
.container {
margin: 0;
padding: 20px;
border-radius: 0;
box-shadow: none;
border: none;
}
}
</style>
</head>
<body>
<div class="container">
<header>
<h1>PM 프로젝트 워크플로우 및 데이터 흐름도</h1>
<p class="subtitle">로컬 개발 인프라 및 핵심 기능(업로드/압축)에 대한 시각화 명세서</p>
</header>
<p>본 문서는 <code>PM_ver4</code> 프로젝트의 시스템 아키텍처, 기능별 워크플로우 및 엔티티 간 데이터 흐름을 시각적으로 구현하여 이해를 돕기 위한 HTML 문서입니다.</p>
<!-- 1. 전체 아키텍처 -->
<h2>1. 전체 시스템 아키텍처 및 데이터 흐름도</h2>
<p>사용자 브라우저에서 출발하여 웹 서버(Express), PostgreSQL, Redis 및 로컬 MinIO(S3) 스토리지까지 연결되는 데이터 흐름도입니다.</p>
<div class="diagram-card">
<div class="mermaid">
graph LR
%% 1. 사용자 영역 (가장 좌측 배치)
subgraph Client [사용자 브라우저]
UI["HTML CSS JS views"]
SocketClient["Socket.io Client"]
end
%% 2. 백엔드 웹서버 영역 (중앙 배치)
subgraph WebServer [백엔드 서버 Port 6565]
Express["Express App"]
Router["Router / Controller"]
AuthMid["SSO / Login Bypass"]
SocketServer["Socket.io Server"]
BullQueue["BullMQ Producer"]
S3SDK["aws-sdk s3 - MinIO 연동"]
PGDriver["pg - PostgreSQL 연동"]
end
%% 3. 백그라운드 워커 영역 (중앙 하단 배치)
subgraph BackgroundWorker [비동기 워커 프로세스]
Worker["BullMQ Worker - 압축 수행"]
ExternalCLI["programs - pdf_thumb / encryp"]
end
%% 4. 인프라 영역 (가장 우측 배치)
subgraph Infrastructure [Docker 가상 환경]
PostgresDB["PostgreSQL DB"]
RedisDB["Redis Broker"]
MinIOStorage["MinIO S3"]
end
%% 좌측 (Client) -> 중앙 (WebServer) 흐름
UI -->|HTTP Request| Express
Express --> UI
SocketClient ---|Socket Event| SocketServer
%% 웹서버 내부 로직 흐름
Express --> AuthMid
AuthMid --> Router
%% 중앙 (WebServer) -> 우측 (Infrastructure) 데이터 흐름
Router -->|Query| PGDriver
PGDriver --> PostgresDB
Router -->|Push Job| BullQueue
BullQueue --> RedisDB
Router -->|Generate URL| S3SDK
S3SDK --> MinIOStorage
%% 좌측 (Client) -> 우측 (Infrastructure) 다이렉트 업로드 흐름
UI -->|Direct Upload| MinIOStorage
%% 워커 동작 흐름 (중앙 하단 -> 우측 및 외부)
Worker -->|Pop Job| RedisDB
Worker -->|Execute CLI| ExternalCLI
Worker -->|Read Write Files| MinIOStorage
Worker -->|Update Status| PGDriver
</div>
</div>
<!-- 2. 주요 기능별 핵심 워크플로우 -->
<h2>2. 주요 기능별 핵심 워크플로우</h2>
<h3>2.1 파일 업로드 워크플로우 <span class="badge">Presigned URL 방식</span></h3>
<p>서버 리소스를 보존하기 위해 브라우저에서 스토리지(MinIO)로 파일을 직접 올릴 수 있게 구현된 프로세스입니다.</p>
<div class="diagram-card">
<div class="mermaid">
sequenceDiagram
autonumber
actor User as 사용자 (브라우저)
participant Server as 백엔드 서버 (Node.js)
participant MinIO as 로컬 MinIO 스토리지
participant DB as 데이터베이스 (PostgreSQL)
User->>Server: 1. 업로드 링크 요청 (POST /:projectId/archive/generateUploadUrl)
Note over Server: Bucket명 변환 미들웨어 작동<br>(PM_TEST_01 -> pm-test-01)
Server->>MinIO: 2. S3 SDK를 통한 Presigned PUT URL 요청
MinIO-->>Server: 3. 제한 시간 설정된 Presigned URL 반환
Server-->>User: 4. Presigned URL 전달
User->>MinIO: 5. 해당 Presigned URL로 직접 파일 업로드 (HTTP PUT)
Note over User,MinIO: 브라우저에서 스토리지로 다이렉트 전송
MinIO-->>User: 6. 업로드 완료 응답 (HTTP 200 OK)
User->>Server: 7. 업로드 정보 DB 반영 요청
Server->>DB: 8. tb_data / _test_tb_data 테이블에 파일 정보 INSERT
DB-->>Server: 9. 삽입 성공
Server-->>User: 10. 업로드 최종 완료 처리
</div>
</div>
<h3>2.2 폴더 비동기 압축 및 다운로드 워크플로우 <span class="badge">BullMQ + Redis 방식</span></h3>
<p>오래 걸리는 압축 다운로드 작업을 백그라운드에서 비동기로 수행하고, 소켓으로 클라이언트에 완료 알림을 제공하는 워크플로우입니다.</p>
<div class="diagram-card">
<div class="mermaid">
sequenceDiagram
autonumber
actor User as 사용자 (브라우저)
participant Server as 백엔드 서버 (Node.js)
participant Redis as Redis (6379)
participant Worker as 백그라운드 Worker
participant MinIO as 로컬 MinIO 스토리지
participant DB as 데이터베이스 (PostgreSQL)
User->>Server: 1. 폴더 압축 다운로드 요청
Server->>DB: 2. tb_download_folder에 'PENDING' 상태로 이력 기록
Server->>Redis: 3. BullMQ를 통해 압축 Job 발행 (Push)
Server-->>User: 4. 압축 작업 시작 안내 응답 (즉시 반환, 화면 안멈춤)
loop Worker 감시
Worker->>Redis: 5. 대기 중인 Job 가져오기 (Pop)
end
Note over Worker: 6. 폴더 내 모든 파일 탐색 및<br>zip 압축 파일 로컬 생성
Worker->>MinIO: 7. 생성된 zip 파일을 S3 스토리지에 업로드
Worker->>DB: 8. tb_download_folder 상태를 'COMPLETED'로 갱신,<br>zip_key 및 만료일(expire_date) 기록
Note over Server,User: 소켓(Socket.io) 또는 폴링 감시
Server->>User: 9. 압축 완료 알림 전송 (Socket.io)
User->>Server: 10. 내 다운로드 리스트 요청 (GET /getMyDownloadList)
Server->>DB: 11. 완료된 zip 다운로드 정보 조회
DB-->>Server: 12. zip 키값 반환
Server-->>User: 13. MinIO zip 다운로드 다운로드 링크 반환
</div>
</div>
<!-- 3. ERD 맵핑 관계 -->
<h2>3. 주요 데이터 스키마 간 맵핑 관계 (Core ERD)</h2>
<p>시스템 비즈니스 로직의 핵심이 되는 6개 주요 테이블 간의 참조 관계와 주요 필드 중심의 ERD 다이어그램입니다.</p>
<div class="diagram-card">
<div class="mermaid">
erDiagram
tb_user {
varchar user_id PK
varchar user_nm
varchar company
varchar dept
varchar group
}
tb_project {
varchar project_id PK
varchar user_id FK
varchar project_nm
varchar short_nm
boolean overview
boolean official_doc
boolean gsim
}
tb_data {
integer data_id PK
varchar project_id FK
varchar user_id FK
boolean is_folder
bigint data_size
text object_key
bigint popup_size
bigint preview_size
text ai_summary
timestamp create_date
}
tb_download_folder {
integer download_id PK
varchar project_id FK
varchar user_id FK
varchar status
text zip_key
timestamp expire_date
boolean made
}
tb_official_doc_file {
integer doc_id PK
varchar project_id FK
varchar uploader FK
varchar doc_number
varchar doc_date
text doc_title
text doc_title_summary
text doc_content_summary
bigint popup_size
bigint preview_size
}
tb_click_log {
integer click_log_id PK
varchar project_id FK
varchar user_id FK
varchar activity
varchar user_ip
text_array path_arr
int_array data_id_arr
}
%% Relationships
tb_user ||--o{ tb_project : "manages"
tb_project ||--o{ tb_data : "contains"
tb_user ||--o{ tb_data : "creates"
tb_project ||--o{ tb_download_folder : "contains"
tb_user ||--o{ tb_download_folder : "requests"
tb_project ||--o{ tb_official_doc_file : "contains"
tb_user ||--o{ tb_official_doc_file : "uploads"
tb_project ||--o{ tb_click_log : "records"
tb_user ||--o{ tb_click_log : "performs"
</div>
</div>
<!-- 4. 사용자 관리 업무 흐름 -->
<h2>4. 사용자 관리 업무 흐름</h2>
<p>신규 사용자 계정을 생성한 뒤, 프로젝트 현장을 등록하고, 해당 현장에 사용자를 배정하여 시스템 기능을 가동하는 일련의 흐름입니다.</p>
<div class="diagram-card">
<div class="mermaid">
graph TD
A["👤 1. 사용자 등록 (tb_user)<br>사용자 아이디, 패스워드, 이름, 회사/소속부서 등록"] --> B["🏗️ 2. 프로젝트 등록 (tb_project)<br>현장 고유
ID, 공식 명칭, 카테고리 구분, 용량제한 설정"]
B --> C["🔗 3. 프로젝트 사용자 등록 (tb_permission)<br>등록된 사용자를 특정 현장에 권한 등급(lev)과 함께 배정 및 연결"]
C --> D["💻 4. 시스템 사용 (tb_data / tb_official_doc_file)<br>배정받은 권한 레벨(Owner, Sub-Master, Worker, Viewer)에
맞춰 시스템 기능 활용"]
%% Styling
style A fill:#fff1f2,stroke:#f43f5e,stroke-width:2px;
style B fill:#fff1f2,stroke:#f43f5e,stroke-width:2px;
style C fill:#fff1f2,stroke:#f43f5e,stroke-width:2px;
style D fill:#be123c,stroke:#be123c,stroke-width:2px,color:#fff;
</div>
</div>
</div>
</body>
</html>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,793 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>통합 관리자 대시보드 구현 계획 및 테이블 명세 가이드</title>
<!-- Google Fonts - Inter & Noto Sans KR -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=Noto+Sans+KR:wght@300;400;500;700&display=swap" rel="stylesheet">
<!-- Mermaid JS for Dynamic Diagrams -->
<script src="https://cdn.jsdelivr.net/npm/mermaid/dist/mermaid.min.js"></script>
<script>
mermaid.initialize({
startOnLoad: true,
theme: 'forest',
themeVariables: {
primaryColor: '#1e5149',
primaryTextColor: '#fff',
lineColor: '#1e5149',
secondaryColor: '#142e29'
}
});
</script>
<style>
:root {
--primary: #1e5149;
--primary-dark: #142e29;
--primary-light: #e9eeed;
--primary-border: #d2dcdb;
--accent: #4db251;
--bg: #f4f7f6;
--card-bg: #ffffff;
--text-main: #1f2937;
--text-muted: #4b5563;
--code-bg: #1e293b;
--code-text: #f8fafc;
--shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.05), 0 2px 4px -2px rgba(0, 0, 0, 0.05);
--sidebar-width: 280px;
}
* {
box-sizing: border-box;
}
body {
font-family: 'Inter', 'Noto Sans KR', sans-serif;
background-color: var(--bg);
color: var(--text-main);
line-height: 1.7;
margin: 0;
padding: 0;
display: flex;
}
/* Sidebar Navigation */
aside {
width: var(--sidebar-width);
background: linear-gradient(180deg, var(--primary-dark) 0%, #0c1a18 100%);
color: #ffffff;
height: 100vh;
position: fixed;
top: 0;
left: 0;
overflow-y: auto;
border-right: 1px solid rgba(255, 255, 255, 0.1);
padding: 24px;
z-index: 100;
}
aside h2 {
font-size: 1.15rem;
font-weight: 700;
margin-bottom: 24px;
color: var(--accent);
display: flex;
align-items: center;
gap: 8px;
border-bottom: 1px solid rgba(255, 255, 255, 0.15);
padding-bottom: 12px;
}
aside ul {
list-style: none;
padding: 0;
margin: 0;
}
aside li {
margin-bottom: 8px;
}
aside a {
color: #b3c5c2;
text-decoration: none;
font-size: 0.9rem;
display: block;
padding: 8px 12px;
border-radius: 6px;
transition: all 0.2s ease;
}
aside a:hover, aside li.active a {
color: #ffffff;
background-color: rgba(255, 255, 255, 0.08);
padding-left: 16px;
}
aside .category-title {
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: #628781;
margin: 16px 0 8px 12px;
font-weight: 700;
}
/* Main Content */
main {
margin-left: var(--sidebar-width);
flex-grow: 1;
padding: 40px 50px;
max-width: 1000px;
}
header {
border-bottom: 2px solid var(--primary-border);
padding-bottom: 24px;
margin-bottom: 40px;
}
header h1 {
font-size: 2.2rem;
font-weight: 700;
margin: 0 0 10px 0;
color: var(--primary);
}
header .subtitle {
font-size: 1.1rem;
color: var(--text-muted);
margin: 0;
}
/* Content Sections */
section {
background-color: var(--card-bg);
border-radius: 12px;
padding: 30px;
margin-bottom: 30px;
box-shadow: var(--shadow);
border: 1px solid var(--primary-border);
}
h2.section-title {
font-size: 1.4rem;
font-weight: 700;
color: var(--primary);
margin-top: 0;
margin-bottom: 20px;
border-left: 4px solid var(--accent);
padding-left: 12px;
}
h3 {
font-size: 1.15rem;
font-weight: 600;
color: var(--primary-dark);
margin-top: 24px;
margin-bottom: 12px;
}
p {
margin: 0 0 16px 0;
color: var(--text-main);
}
/* Callouts (GitHub alerts) */
.callout {
border-left: 4px solid;
padding: 16px;
margin-bottom: 20px;
border-radius: 0 8px 8px 0;
}
.callout-note {
background-color: #f0f7ff;
border-color: #0066cc;
color: #004080;
}
.callout-important {
background-color: #fff8f8;
border-color: #ef4444;
color: #991b1b;
}
.callout-title {
font-weight: 700;
margin-bottom: 6px;
display: flex;
align-items: center;
gap: 6px;
}
/* Tables */
table {
width: 100%;
border-collapse: collapse;
margin: 20px 0;
font-size: 0.9rem;
border-radius: 8px;
overflow: hidden;
border: 1px solid var(--primary-border);
}
th {
background-color: var(--primary);
color: #ffffff;
font-weight: 600;
text-align: left;
padding: 12px 16px;
}
td {
padding: 12px 16px;
border-bottom: 1px solid var(--primary-border);
color: var(--text-main);
vertical-align: top;
}
tr:last-child td {
border-bottom: none;
}
tr:nth-child(even) td {
background-color: #f9fafb;
}
/* Code Blocks */
pre {
background-color: var(--code-bg);
color: var(--code-text);
padding: 18px;
border-radius: 8px;
font-family: 'Consolas', 'Courier New', Courier, monospace;
font-size: 0.85rem;
overflow-x: auto;
margin: 16px 0;
box-shadow: inset 0 2px 4px rgba(0, 0, 0, 0.1);
}
code {
font-family: 'Consolas', 'Courier New', Courier, monospace;
background-color: #f1f5f9;
color: #0f172a;
padding: 2px 6px;
border-radius: 4px;
font-size: 0.85rem;
}
pre code {
background-color: transparent;
color: inherit;
padding: 0;
border-radius: 0;
font-size: inherit;
}
.method-badge {
display: inline-block;
padding: 3px 8px;
border-radius: 4px;
font-size: 0.75rem;
font-weight: 700;
color: #ffffff;
margin-right: 6px;
}
.method-get { background-color: #0ea5e9; }
.method-post { background-color: #22c55e; }
.method-put { background-color: #f59e0b; }
.method-delete { background-color: #ef4444; }
.api-url {
font-weight: 600;
font-family: monospace;
}
/* List Styling */
ul {
padding-left: 20px;
margin-bottom: 16px;
}
li {
margin-bottom: 6px;
}
/* Diagram Container */
.diagram-container {
display: flex;
justify-content: center;
margin: 24px 0;
background-color: #ffffff;
padding: 20px;
border-radius: 8px;
border: 1px solid var(--primary-border);
}
</style>
</head>
<body>
<!-- Sidebar Navigation -->
<aside>
<h2>📁 PM_ver4 Admin Guide</h2>
<ul>
<li><a href="#overview">가이드 소개</a></li>
</ul>
<div class="category-title">01. 시스템 아키텍처</div>
<ul>
<li><a href="#architecture">전체 구조 & 로직</a></li>
<li><a href="#migration">단계별 구현 계획</a></li>
</ul>
<div class="category-title">02. 데이터 모델 (ERD)</div>
<ul>
<li><a href="#schema-design">스키마 구조 & 관계</a></li>
</ul>
<div class="category-title">03. 화면별 데이터 매핑</div>
<ul>
<li><a href="#screen-dashboard">1. 종합 용량 및 접속자</a></li>
<li><a href="#screen-project">2. 프로젝트 관리</a></li>
<li><a href="#screen-banner">3. 실시간 배너 공지</a></li>
<li><a href="#screen-user">4. 사용자 관리</a></li>
<li><a href="#screen-audit">5. 감사 로그 조회</a></li>
<li><a href="#screen-policy">6. 자동 삭제 정책 설정</a></li>
<li><a href="#screen-codes">7. 공통 코드 관리</a></li>
</ul>
<div class="category-title">04. 백엔드 API 설계</div>
<ul>
<li><a href="#api-endpoints">핵심 CRUD API 목록</a></li>
</ul>
</aside>
<!-- Main Content -->
<main>
<header id="overview">
<h1>통합 관리자 대시보드 구현 계획 및 테이블 명세 가이드</h1>
<p class="subtitle">관리자 화면(Admin Panel) 마이그레이션 및 데이터베이스 연동 설계서</p>
</header>
<!-- Section 1 -->
<section id="architecture">
<h2 class="section-title">1. 전체 시스템 아키텍처 및 구현 계획</h2>
<p>본 설계서는 <code>관리자화면_통합대시보드_UI제안.html</code> 정적 파일의 사용자 동작 시뮬레이션을 프로덕션 급 서버 인프라에 안착하기 위한 설계 명세입니다. 시스템 환경은 Node.js / PostgreSQL / MinIO S3 및 Redis Queue 아키텍처를 준수합니다.</p>
<div class="diagram-container">
<div class="mermaid">
graph TD
Client[Browser Admin Page] -->|REST API Requests| Backend[Express.js Web Server]
Client -->|WebSocket Event Channel| SocketServer[Socket.io Server]
Backend -->|SQL Query / PG client| DB[(PostgreSQL Database)]
Backend -->|Enqueue / Monitor| Redis[(Redis / BullMQ Queue)]
SocketServer -->|Active Session Cache| Memory[Node.js Process Memory]
</div>
</div>
<div class="callout callout-note">
<div class="callout-title"> 백엔드 연동 방식</div>
<div class="callout-content">
본 시스템은 관리 업무의 연속성 확보 및 동시성 처리를 위해 REST API 채널 외에도 Socket.io 서버 채널을 동시에 가용하여 실시간 웹소켓 통신을 처리하도록 구성됩니다.
</div>
</div>
</section>
<!-- Section 2 -->
<section id="migration">
<h2 class="section-title">구현 단계별 개발 로드맵</h2>
<h3>[1단계] 데이터베이스 스키마 확장 및 마이그레이션</h3>
<ul>
<li>공통 코드 분류 및 세부 값 제어를 위한 <code>code_master</code><code>code_detail</code> 테이블 신설.</li>
<li><strong>기존 테이블 활용 (컬럼 추가 없음)</strong>: 기존에 존재하는 프로젝트 테이블(<code>tb_project</code>)의 <code>category</code> 컬럼과 사용자 테이블(<code>tb_user</code>)의 <code>"group"</code> 컬럼을 <code>code_detail.base_code</code> 외래키 참조 구조로 그대로 연동하여 불필요한 테이블 팽창 및 컬럼 추가를 차단합니다.</li>
<li>시스템 공통 자동 보존 및 삭제 임계치를 독립적으로 제어하기 위한 <strong><code>tb_system_policy</code> (시스템 공통 정책 테이블)</strong> 신설.</li>
<li>실시간 배너 공지 이력 보관을 위한 <code>tb_banner_notice</code> 및 정기 자동삭제 실행 결과를 기록하기 위한 <code>tb_auto_clean_log</code> 신설.</li>
</ul>
<h3>[2단계] 백엔드 RESTful API 및 WebSocket 핸들러 개발</h3>
<ul>
<li><strong>REST API</strong>: 각 기능 및 공통 코드 데이터의 CRUD API Endpoint 구축 (Express Controller 연동).</li>
<li><strong>WebSocket</strong>: <code>socket.js</code>에 접속한 클라이언트 세션을 <code>users</code> 메모리 맵에 보관 및 관리자가 강제퇴장(<code>forcedLogout</code>) 이벤트를 호출하면 해당 클라이언트 소켓의 접속을 끊고 세션을 제거하도록 구현.</li>
</ul>
<h3>[3단계] 프론트엔드 어드민 대시보드 UI 연동</h3>
<ul>
<li>프로토타입 디자인 컨셉(Pretendard 서체, Forest Green 테마 색상)을 CSS 변수로 그대로 반영.</li>
<li>모달 다이얼로그와 탭 렌더링에 실시간 API 데이터 통신 로직 바인딩.</li>
</ul>
</section>
<!-- Section 3 -->
<section id="schema-design">
<h2 class="section-title">2. 데이터베이스 스키마 설계 (ERD)</h2>
<p>기존에 이미 보유한 <code>tb_project.category</code><code>tb_user."group"</code> 구조를 활용한 관계 정의는 다음과 같습니다.</p>
<div class="diagram-container">
<div class="mermaid">
classDiagram
class code_master {
+main_code : VARCHAR(30) PK
+main_code_nm : VARCHAR(100)
+use_yn : CHAR(1)
+rmk : VARCHAR(255)
}
class code_detail {
+main_code : VARCHAR(30) PK/FK
+sub_code : VARCHAR(30) PK
+base_code : VARCHAR(61) UNIQUE
+code_nm : VARCHAR(100)
+sort_ord : INT
+use_yn : CHAR(1)
+rmk : VARCHAR(255)
}
class tb_project {
+project_id : VARCHAR(50) PK
+project_nm : VARCHAR(100)
+category : VARCHAR(50) FK
+limit_storage : INT
+is_active : BOOLEAN
}
class tb_system_policy {
+policy_id : INT PK
+policy_key : VARCHAR(50) UNIQUE
+limit_file_count : INT
+limit_days : INT
+is_active : BOOLEAN
+upd_date : TIMESTAMP
}
class tb_user {
+user_id : VARCHAR(50) PK
+user_nm : VARCHAR(50)
+user_pw : VARCHAR(255)
+company : VARCHAR(50)
+dept : VARCHAR(50)
+position : VARCHAR(50)
+group : VARCHAR(50) FK
+is_resigned : BOOLEAN
}
class tb_permission {
+project_id : VARCHAR(50) PK/FK
+user_id : VARCHAR(50) PK/FK
+lev : INT
}
class tb_banner_notice {
+banner_id : SERIAL PK
+project_id : VARCHAR(50) FK
+reg_date : DATE
+start_date : DATE
+end_date : DATE
+notice_text : TEXT
+status_code : VARCHAR(61) FK
}
class tb_log {
+log_id : SERIAL PK
+project_id : VARCHAR(50)
+activity : VARCHAR(100)
+user_id : VARCHAR(50)
+user_ip : VARCHAR(50)
+log_date : TIMESTAMP
+path_arr : TEXT[]
}
code_master "1" --> "0..*" code_detail
code_detail "1" --> "0..*" tb_project : "category 참조"
code_detail "1" --> "0..*" tb_user : "group 참조"
code_detail "1" --> "0..*" tb_banner_notice : "status_code 참조"
tb_project "1" --> "0..*" tb_permission
tb_user "1" --> "0..*" tb_permission
tb_project "1" --> "0..*" tb_banner_notice
</div>
</div>
</section>
<!-- Section 4 -->
<section id="screen-dashboard">
<h2 class="section-title">3. 화면별 연동 테이블 및 데이터베이스 상세 매핑</h2>
<h3>📊 화면 1: 종합 용량 및 접속자 현황 (Dashboard)</h3>
<p><strong>주요 데이터 흐름 및 활용 테이블:</strong></p>
<ul>
<li><strong>전체 사용 용량 KPI & 현장별 게이지바</strong>:
<code>tb_project</code> 테이블의 한도 용량(<code>storage_byte</code>)과 <code>tb_data</code> 테이블의 실 누적 용량(<code>data_size</code>) 합산 결과 및 파일 건수를 <code>COUNT()</code>하여 렌더링.
</li>
<li><strong>실시간 접속자 세션 목록</strong>:
데이터베이스를 경유하지 않고 Node.js 메인 프로세스(<code>socket.js</code>)의 메모리 세션 해시맵에서 직접 연결 상태(ID, IP, 위치 경로)를 추출.
</li>
<li><strong>대기 중인 압축작업</strong>:
Redis(BullMQ Queue) 대기열 API를 조회하여 보류 중인 백그라운드 압축 다운로드 개수 파악.
</li>
</ul>
</section>
<section id="screen-project">
<h3>🏗️ 화면 2: 프로젝트 관리 (Project Management)</h3>
<p><strong>주요 데이터 흐름 및 활용 테이블:</strong></p>
<ul>
<li><strong>좌측 프로젝트 그리드</strong>: <code>tb_project</code> 테이블 전체 데이터와 <code>code_detail</code>의 카테고리 코드 한글 명칭(TDC, GPD 등)을 <code>tb_project.category</code> 외래키 관계를 조인하여 렌더링.</li>
<li><strong>우측 참여 권한 사용자 목록</strong>: <code>tb_permission</code> $\bowtie$ <code>tb_user</code> 조인을 통해 해당 현장에 기속된 사용자 리스트업 및 등급 인라인 셀렉트 업데이트.</li>
<li><strong>사용자 배정 추가 모달</strong>: <code>tb_user</code>의 전체 사용자 데이터와 현재 프로젝트에 속해 있지 않은 유저 차집합 연산으로 가용 유저 목록 구성.</li>
</ul>
</section>
<section id="screen-banner">
<h3>📢 화면 3: 실시간 배너 공지 (Banner Notice)</h3>
<p><strong>주요 데이터 흐름 및 활용 테이블:</strong></p>
<ul>
<li><strong>공지사항 이력 및 등록 폼</strong>: <code>tb_banner_notice</code> 테이블 CRUD 매핑.</li>
<li><strong>송출 상태 계산</strong>:
오늘 날짜 기준 <code>start_date</code><code>end_date</code> 조건을 체크하여 공통코드 매핑 상태(<code>NOTICE_STATUS_active</code>, <code>NOTICE_STATUS_scheduled</code>, <code>NOTICE_STATUS_expired</code>)를 동적 할당.
</li>
<li><strong>검색 필터</strong>: 송출상태 및 등록일 범위(From ~ To) 기준 <code>WHERE status_code = ? AND reg_date BETWEEN ? AND ?</code> 쿼리 연동.</li>
</ul>
</section>
<section id="screen-user">
<h3>👥 화면 4: 사용자 관리 (User Management)</h3>
<p><strong>주요 데이터 흐름 및 활용 테이블:</strong></p>
<ul>
<li><strong>좌측 계정 목록</strong>: <code>tb_user</code> 테이블 및 <code>code_detail</code> 권한그룹(기존 <code>"group"</code> 컬럼 재사용) 매핑 렌더링.</li>
<li><strong>우측 참여 프로젝트 목록</strong>: <code>tb_permission</code> $\bowtie$ <code>tb_project</code> 조인을 활용하여 선택 유저가 소속된 모든 프로젝트 ID 및 현장명 출력.</li>
</ul>
</section>
<section id="screen-audit">
<h3>🔎 화면 5: 감사 로그 조회 (Audit Logs)</h3>
<p><strong>주요 데이터 흐름 및 활용 테이블:</strong></p>
<ul>
<li><strong>시스템 감사 이력 목록</strong>:
파일 삭제/이동/다운로드 이벤트를 적재하는 <code>tb_log</code> 테이블을 필터 검색 쿼리(사용자 ID, 액션타입)와 연동하여 리스트 렌더링.
</li>
</ul>
</section>
<section id="screen-policy">
<h3>⚙️ 화면 6: 자동 보존 및 파일 삭제 정책 설정 (Delete Policy)</h3>
<p><strong>주요 데이터 흐름 및 활용 테이블:</strong></p>
<ul>
<li><strong>시스템 공통 자동 삭제 정책 설정 폼</strong>:
프로젝트 단위 설정을 탈피하고, 신설된 <code>tb_system_policy</code>의 단일 글로벌 레코드를 제어.
</li>
<li><strong>자동 삭제 정기 실행 이력</strong>:
정기 스케줄러 배치 구동 시의 로그 기록을 <code>tb_auto_clean_log</code>에서 리스트업. 설정값 저장 및 기록 적재 시 프로젝트 ID 대신 <code>'SYSTEM'</code> 기록 식별자를 사용.
</li>
</ul>
</section>
<section id="screen-codes">
<h3>🔑 화면 7: 공통 코드 관리 (Common Code Management)</h3>
<p><strong>주요 데이터 흐름 및 활용 테이블:</strong></p>
<ul>
<li><strong>상단 마스터 대분류</strong>: <code>code_master</code> 테이블 CRUD.</li>
<li><strong>하단 상세 소분류</strong>: <code>code_detail</code> 테이블 CRUD.
<ul>
<li>대분류 선택이 없을 시 소분류 등록 폼 진입 차단 유효성 제어.</li>
<li>소분류의 <code>base_code</code> 컬럼은 <code>main_code || '_' || sub_code</code> 조합으로 자동 동기화.</li>
<li>대분류 삭제 시 외래키 <code>ON DELETE CASCADE</code> 명세를 통하여 소분류 레코드가 데이터베이스에서 연쇄적으로 자동 청소되도록 설정.</li>
</ul>
</li>
</ul>
</section>
<!-- Section 5 -->
<section id="api-endpoints">
<h2 class="section-title">4. 핵심 백엔드 CRUD API Endpoint 설계안</h2>
<p>프론트엔드-백엔드 간 통신을 위해 구성되어야 할 RESTful API 리스트입니다.</p>
<h3>1. 프로젝트 관리 API (<code>/api/admin/projects</code>)</h3>
<table>
<thead>
<tr>
<th style="width: 15%;">메소드</th>
<th style="width: 35%;">엔드포인트</th>
<th style="width: 50%;">설명</th>
</tr>
</thead>
<tbody>
<tr>
<td><span class="method-badge method-get">GET</span></td>
<td class="api-url">/</td>
<td>전체 프로젝트 및 카테고리 정보 조회</td>
</tr>
<tr>
<td><span class="method-badge method-post">POST</span></td>
<td class="api-url">/</td>
<td>신규 프로젝트 등록</td>
</tr>
<tr>
<td><span class="method-badge method-put">PUT</span></td>
<td class="api-url">/:id</td>
<td>특정 프로젝트 내용(카테고리 category 값, 용량제한, 활성화) 갱신</td>
</tr>
<tr>
<td><span class="method-badge method-delete">DELETE</span></td>
<td class="api-url">/:id</td>
<td>프로젝트 영구 삭제 (참여 권한 및 메타데이터 자동 CASCADE)</td>
</tr>
</tbody>
</table>
<h3>2. 프로젝트 권한 배정 API (<code>/api/admin/permissions</code>)</h3>
<table>
<thead>
<tr>
<th style="width: 15%;">메소드</th>
<th style="width: 35%;">엔드포인트</th>
<th style="width: 50%;">설명</th>
</tr>
</thead>
<tbody>
<tr>
<td><span class="method-badge method-get">GET</span></td>
<td class="api-url">/project/:projectId</td>
<td>특정 현장에 참여 중인 유저 목록 조회</td>
</tr>
<tr>
<td><span class="method-badge method-post">POST</span></td>
<td class="api-url">/assign</td>
<td>현장에 특정 사용자 권한 신규 부여 (다중 배정)</td>
</tr>
<tr>
<td><span class="method-badge method-put">PUT</span></td>
<td class="api-url">/update</td>
<td>배정 유저의 권한 등급(lev) 수정</td>
</tr>
<tr>
<td><span class="method-badge method-delete">DELETE</span></td>
<td class="api-url">/remove</td>
<td>현장 참여 권한 배정 제외 (매핑 행 삭제)</td>
</tr>
</tbody>
</table>
<h3>3. 실시간 배너 공지 API (<code>/api/admin/banners</code>)</h3>
<table>
<thead>
<tr>
<th style="width: 15%;">메소드</th>
<th style="width: 35%;">엔드포인트</th>
<th style="width: 50%;">설명</th>
</tr>
</thead>
<tbody>
<tr>
<td><span class="method-badge method-get">GET</span></td>
<td class="api-url">/</td>
<td>배너 송출 이력 조회 (상태, 날짜검색 필터 지원)</td>
</tr>
<tr>
<td><span class="method-badge method-post">POST</span></td>
<td class="api-url">/</td>
<td>신규 배너 공지 작성 및 등록</td>
</tr>
<tr>
<td><span class="method-badge method-put">PUT</span></td>
<td class="api-url">/stop/:id</td>
<td>공지 수동 송출 중지 (상태를 expired로 강제 업데이트)</td>
</tr>
</tbody>
</table>
<h3>4. 사용자 관리 API (<code>/api/admin/users</code>)</h3>
<table>
<thead>
<tr>
<th style="width: 15%;">메소드</th>
<th style="width: 35%;">엔드포인트</th>
<th style="width: 50%;">설명</th>
</tr>
</thead>
<tbody>
<tr>
<td><span class="method-badge method-get">GET</span></td>
<td class="api-url">/</td>
<td>전체 계정 정보 및 권한그룹 조회</td>
</tr>
<tr>
<td><span class="method-badge method-get">GET</span></td>
<td class="api-url">/:id/permissions</td>
<td>해당 유저가 참여 권한을 지닌 프로젝트 목록 조회</td>
</tr>
<tr>
<td><span class="method-badge method-post">POST</span></td>
<td class="api-url">/</td>
<td>신규 사용자 계정 등록 (패스워드 bcrypt 암호화)</td>
</tr>
<tr>
<td><span class="method-badge method-put">PUT</span></td>
<td class="api-url">/:id</td>
<td>사용자 정보(권한 그룹 group 포함) 및 재직 상태 갱신</td>
</tr>
<tr>
<td><span class="method-badge method-delete">DELETE</span></td>
<td class="api-url">/:id</td>
<td>사용자 계정 삭제 (권한 정보 및 설정 연쇄 삭제)</td>
</tr>
</tbody>
</table>
<h3>5. 공통 코드 관리 API (<code>/api/admin/common-codes</code>)</h3>
<table>
<thead>
<tr>
<th style="width: 15%;">메소드</th>
<th style="width: 35%;">엔드포인트</th>
<th style="width: 50%;">설명</th>
</tr>
</thead>
<tbody>
<tr>
<td><span class="method-badge method-get">GET</span></td>
<td class="api-url">/masters</td>
<td>대분류 마스터 코드 목록 조회</td>
</tr>
<tr>
<td><span class="method-badge method-post">POST</span></td>
<td class="api-url">/masters</td>
<td>신규 대분류 등록</td>
</tr>
<tr>
<td><span class="method-badge method-put">PUT</span></td>
<td class="api-url">/masters/:code</td>
<td>대분류 수정</td>
</tr>
<tr>
<td><span class="method-badge method-delete">DELETE</span></td>
<td class="api-url">/masters/:code</td>
<td>대분류 삭제 (하위 소분류 자동 연쇄 삭제)</td>
</tr>
<tr>
<td><span class="method-badge method-get">GET</span></td>
<td class="api-url">/details/:mainCode</td>
<td>선택된 대분류에 해당하는 소분류 정렬 조회</td>
</tr>
<tr>
<td><span class="method-badge method-post">POST</span></td>
<td class="api-url">/details</td>
<td>신규 소분류 등록 (base_code 자동 연산 생성)</td>
</tr>
<tr>
<td><span class="method-badge method-put">PUT</span></td>
<td class="api-url">/details/:mainCode/:subCode</td>
<td>소분류 수정 (명칭, 정렬순서, 사용여부)</td>
</tr>
<tr>
<td><span class="method-badge method-delete">DELETE</span></td>
<td class="api-url">/details/:mainCode/:subCode</td>
<td>소분류 삭제</td>
</tr>
</tbody>
</table>
<h3>6. 시스템 공통 보존 정책 API (<code>/api/admin/system-policy</code>)</h3>
<table>
<thead>
<tr>
<th style="width: 15%;">메소드</th>
<th style="width: 35%;">엔드포인트</th>
<th style="width: 50%;">설명</th>
</tr>
</thead>
<tbody>
<tr>
<td><span class="method-badge method-get">GET</span></td>
<td class="api-url">/</td>
<td>시스템 글로벌 보존 정책 조회</td>
</tr>
<tr>
<td><span class="method-badge method-post">POST</span></td>
<td class="api-url">/update</td>
<td>글로벌 보존 정책 값 갱신 및 저장</td>
</tr>
</tbody>
</table>
</section>
</main>
</body>
</html>

View File

@@ -0,0 +1,586 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>PM_ver4 통합 관리자 어플리케이션 화면설계서</title>
<!-- Google Fonts - Inter & Noto Sans KR -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=Noto+Sans+KR:wght@300;400;500;700&display=swap" rel="stylesheet">
<style>
:root {
--primary: #1e5149;
--primary-dark: #142e29;
--primary-soft: #e9eeed;
--border: #d2dcdb;
--accent: #4db251;
--bg: #f4f7f6;
--card-bg: #ffffff;
--text-main: #1f2937;
--text-muted: #4b5563;
--shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.05), 0 2px 4px -2px rgba(0, 0, 0, 0.05);
--sidebar-width: 280px;
/* UI Status Colors */
--color-active: #4db251;
--bg-active: #eef8ee;
--color-inactive: #a5b9b6;
--bg-inactive: #f1f5f9;
--color-danger: #ef4444;
--bg-danger: #fee2e2;
--color-warning: #ff9800;
--bg-warning: #fff5e6;
}
* {
box-sizing: border-box;
}
body {
font-family: 'Inter', 'Noto Sans KR', sans-serif;
background-color: var(--bg);
color: var(--text-main);
line-height: 1.7;
margin: 0;
padding: 0;
display: flex;
}
/* Sidebar Navigation */
aside {
width: var(--sidebar-width);
background: linear-gradient(180deg, var(--primary-dark) 0%, #0c1a18 100%);
color: #ffffff;
height: 100vh;
position: fixed;
top: 0;
left: 0;
overflow-y: auto;
border-right: 1px solid rgba(255, 255, 255, 0.1);
padding: 24px;
z-index: 100;
}
aside h2 {
font-size: 1.15rem;
font-weight: 700;
margin-bottom: 24px;
color: var(--accent);
display: flex;
align-items: center;
gap: 8px;
border-bottom: 1px solid rgba(255, 255, 255, 0.15);
padding-bottom: 12px;
}
aside ul {
list-style: none;
padding: 0;
margin: 0;
}
aside li {
margin-bottom: 8px;
}
aside a {
color: #b3c5c2;
text-decoration: none;
font-size: 0.9rem;
display: block;
padding: 8px 12px;
border-radius: 6px;
transition: all 0.2s ease;
}
aside a:hover, aside li.active a {
color: #ffffff;
background-color: rgba(255, 255, 255, 0.08);
padding-left: 16px;
}
aside .category-title {
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: #628781;
margin: 16px 0 8px 12px;
font-weight: 700;
}
/* Main Content */
main {
margin-left: var(--sidebar-width);
flex-grow: 1;
padding: 40px 50px;
max-width: 1000px;
}
header {
border-bottom: 2px solid var(--border);
padding-bottom: 24px;
margin-bottom: 40px;
}
header h1 {
font-size: 2.2rem;
font-weight: 700;
margin: 0 0 10px 0;
color: var(--primary);
}
header .subtitle {
font-size: 1.1rem;
color: var(--text-muted);
margin: 0;
}
/* Content Sections */
section {
background-color: var(--card-bg);
border-radius: 12px;
padding: 30px;
margin-bottom: 30px;
box-shadow: var(--shadow);
border: 1px solid var(--border);
}
h2.section-title {
font-size: 1.4rem;
font-weight: 700;
color: var(--primary);
margin-top: 0;
margin-bottom: 20px;
border-left: 4px solid var(--accent);
padding-left: 12px;
}
h3 {
font-size: 1.2rem;
font-weight: 600;
color: var(--primary-dark);
margin-top: 24px;
margin-bottom: 12px;
border-bottom: 1px solid #f1f5f9;
padding-bottom: 8px;
}
h4 {
font-size: 1.05rem;
font-weight: 600;
color: var(--primary);
margin-top: 16px;
margin-bottom: 8px;
}
p {
margin: 0 0 16px 0;
color: var(--text-main);
}
/* Color Chips */
.color-palette {
display: flex;
flex-wrap: wrap;
gap: 16px;
margin: 20px 0;
}
.color-chip {
flex: 1 1 200px;
border: 1px solid var(--border);
border-radius: 8px;
overflow: hidden;
background-color: #ffffff;
box-shadow: var(--shadow);
}
.color-box {
height: 60px;
width: 100%;
}
.color-details {
padding: 10px 12px;
}
.color-name {
font-weight: 700;
font-size: 0.85rem;
margin-bottom: 2px;
}
.color-value {
font-family: monospace;
font-size: 0.8rem;
color: var(--text-muted);
}
/* Layout Preview map */
.layout-preview {
background-color: #1e293b;
color: #cbd5e1;
padding: 20px;
border-radius: 8px;
font-family: monospace;
font-size: 0.8rem;
white-space: pre;
overflow-x: auto;
margin: 20px 0;
line-height: 1.4;
}
/* Status Badge */
.status-badge {
display: inline-block;
padding: 2px 8px;
border-radius: 4px;
font-size: 0.8rem;
font-weight: 600;
}
.status-badge-active { background-color: var(--bg-active); color: var(--color-active); }
.status-badge-inactive { background-color: var(--bg-inactive); color: var(--color-inactive); }
.status-badge-danger { background-color: var(--bg-danger); color: var(--color-danger); }
.status-badge-warning { background-color: var(--bg-warning); color: var(--color-warning); }
/* Tables */
table {
width: 100%;
border-collapse: collapse;
margin: 16px 0;
font-size: 0.88rem;
border-radius: 8px;
overflow: hidden;
border: 1px solid var(--border);
}
th {
background-color: var(--primary-soft);
color: var(--primary-dark);
font-weight: 600;
text-align: left;
padding: 10px 14px;
border-bottom: 2px solid var(--border);
}
td {
padding: 10px 14px;
border-bottom: 1px solid var(--border);
color: var(--text-main);
}
tr:last-child td {
border-bottom: none;
}
/* Lists */
ul {
padding-left: 20px;
margin-bottom: 16px;
}
li {
margin-bottom: 6px;
}
.spec-card {
border: 1px solid var(--border);
border-radius: 8px;
padding: 16px;
margin-bottom: 16px;
background-color: #f9fafb;
}
</style>
</head>
<body>
<!-- Sidebar Navigation -->
<aside>
<h2>📁 PM_ver4 Spec</h2>
<ul>
<li><a href="#overview">설계서 소개</a></li>
</ul>
<div class="category-title">01. 디자인 시스템</div>
<ul>
<li><a href="#design-system">디자인 가이드라인</a></li>
</ul>
<div class="category-title">02. 공통 프레임</div>
<ul>
<li><a href="#layout">App Frame 레이아웃</a></li>
</ul>
<div class="category-title">03. 화면별 UI/UX 명세</div>
<ul>
<li><a href="#screen-dashboard">📊 종합 용량 및 접속자</a></li>
<li><a href="#screen-project">🏗️ 프로젝트 관리</a></li>
<li><a href="#screen-banner">📢 실시간 배너 공지</a></li>
<li><a href="#screen-user">👥 사용자 관리</a></li>
<li><a href="#screen-audit">🔎 감사 로그 조회</a></li>
<li><a href="#screen-policy">⚙️ 보관 및 삭제 설정</a></li>
<li><a href="#screen-codes">🔑 공통 코드 관리</a></li>
</ul>
</aside>
<!-- Main Content -->
<main>
<header id="overview">
<h1>PM_ver4 통합 관리자 어플리케이션 화면설계서</h1>
<p class="subtitle">UI/UX Specification - 대시보드 및 관리자 화면 기능 명세서</p>
</header>
<!-- Section 1 -->
<section id="design-system">
<h2 class="section-title">1. 공통 UI 가이드라인 및 디자인 시스템</h2>
<p>관리자 패널의 모든 화면 레이아웃, 컴포넌트 명세, 사용자 액션 및 데이터 정합성 검증 규칙은 본 시스템의 디자인 규칙을 준수합니다.</p>
<h3>① 색상 토큰 (Color Tokens)</h3>
<div class="color-palette">
<div class="color-chip">
<div class="color-box" style="background-color: #1e5149;"></div>
<div class="color-details">
<div class="color-name">Primary Forest Green</div>
<div class="color-value">#1e5149</div>
</div>
</div>
<div class="color-chip">
<div class="color-box" style="background-color: #142e29;"></div>
<div class="color-details">
<div class="color-name">Dark Teal Sidebar</div>
<div class="color-value">#142e29</div>
</div>
</div>
<div class="color-chip">
<div class="color-box" style="background-color: #d2dcdb;"></div>
<div class="color-details">
<div class="color-name">Light Green Gray Border</div>
<div class="color-value">#d2dcdb</div>
</div>
</div>
<div class="color-chip">
<div class="color-box" style="background-color: #e9eeed;"></div>
<div class="color-details">
<div class="color-name">Soft Accent Green BG</div>
<div class="color-value">#e9eeed</div>
</div>
</div>
</div>
<h3>② 서체 (Typography)</h3>
<ul>
<li><strong>서체 패밀리</strong>: <code>'Pretendard Variable', 'Pretendard', 'Inter', 'Noto Sans KR', sans-serif</code></li>
<li><strong>글꼴 크기 명세</strong>:
<ul>
<li>페이지 메인 타이틀: <code>1.25rem</code> (700 Bold)</li>
<li>카드 타이틀: <code>1.05rem</code> (700 Bold)</li>
<li>일반 본문 및 테이블 데이터: <code>0.875rem</code> (500 Medium / 600 Semi-Bold)</li>
<li>Muted 보조 텍스트: <code>0.8rem</code> / <code>0.75rem</code></li>
</ul>
</li>
</ul>
<h3>③ 공통 그리드 & 테이블 (Table Grid Rules)</h3>
<ul>
<li><strong>순번 표시 (NO)</strong>: 모든 테이블 그리드의 1열은 데이터 인덱스 번호(<code>NO</code>)를 필수 노출합니다.</li>
<li><strong>가로 구분선</strong>: 답답한 디자인을 방지하기 위해 세로 테두리(Vertical Borders)는 일절 노출하지 않으며, 가로 행 구분선만 노출합니다.</li>
<li><strong>행 선택 인터랙션</strong>: 마우스 호버 시 <code>var(--primary-soft)</code> 배경색을 지정하며, 클릭 행 활성화 시 텍스트 두께 변경으로 인한 줄높이 왜곡이 없도록 패딩 상속(inherit) 속성을 적용하여 균일한 행 높이를 유지합니다.</li>
</ul>
<h3>④ 공통 모달 팝업 (Modal Overlay Rules)</h3>
<ul>
<li><strong>백드롭</strong>: <code>rgba(20, 30, 29, 0.6)</code> 반투명 딤 및 <code>backdrop-filter: blur(4px)</code> 효과로 모달 포커스를 고정합니다.</li>
<li><strong>트랜지션</strong>: <code>fade-in</code><code>slide-up (translateY 30px to 0)</code>이 일괄 적용되어 매끄러운 팝업 동작을 보장합니다.</li>
</ul>
</section>
<!-- Section 2 -->
<section id="layout">
<h2 class="section-title">2. 레이아웃 구조 설계 (App Frame Layout)</h2>
<p>LNB, 상단 헤더, 그리고 메인 콘텐츠 탭 영역의 수평/수직 분할 아키텍처는 다음과 같습니다.</p>
<div class="layout-preview">
+-------------------------------------------------------------------------+
| LNB (좌측 사이드바) | Main Header (상단 헤더) |
| 📁 PM_ver4 Admin | [Header Title] [Admin Profile] |
|-------------------------+-----------------------------------------------|
| - Dashboards | Main Content (메인 콘텐츠 탭 영역) |
| 📊 종합 용량/접속자 | |
| - 프로젝트 관리 | +-----------------------------------------+ |
| 🏗️ 프로젝트 관리 | | 카드 1 (필터 / 테이블 리스트) | |
| 📢 실시간 배너 공지 | +-----------------------------------------+ |
| - 사용자 및 권한 | | 카드 2 (상세정보 뷰 / 팝업 연동 리스트) | |
| 👥 사용자 관리 | +-----------------------------------------+ |
| - 시스템 감사 및 환경 | |
| 🔎 감사 로그 조회 | |
| ⚙️ 자동 삭제 설정 | |
| 🔑 공통 코드 관리 | |
+-------------------------------------------------------------------------+</div>
</section>
<!-- Section 3 -->
<section id="screen-dashboard">
<h2 class="section-title">3. 화면별 상세 UI 및 기능 설계</h2>
<h3>📊 화면 1: 종합 용량 및 접속자 현황 (Dashboard)</h3>
<p>상단 3열 KPI 요약 카드와 하단 스토리지 프로그레스바 및 실시간 소켓 접속자 테이블 구조입니다.</p>
<div class="spec-card">
<h4>① 주요 UI 컴포넌트</h4>
<ul>
<li><strong>스토리지 KPI 카드</strong>: 전체 현장의 총 한도 용량 대비 누적 사용 용량 실시간 합산 표시 (예: <code>💾 9.70 GB / 20 GB</code>).</li>
<li><strong>접속자 KPI 카드</strong>: 현재 소켓 서버 연결 세션 수 노출.</li>
<li><strong>압축작업 KPI 카드</strong>: Redis(BullMQ) 내 대기중인 압축 건수.</li>
<li><strong>현장별 스토리지 사용 현황</strong>: 각 프로젝트 ID/명칭, 게이지바 및 사용량 정보(GB / 백분율% / 파일수량개) 동시 렌더링.</li>
</ul>
<h4>② 실시간 접속 현황 테이블 사양</h4>
<table>
<thead>
<tr>
<th style="width: 10%;">NO</th>
<th style="width: 25%;">사용자 ID</th>
<th style="width: 25%;">접속 IP</th>
<th style="width: 25%;">현재 조회 경로</th>
<th style="width: 15%;">작업</th>
</tr>
</thead>
<tbody>
<tr>
<td>1</td>
<td>admin_test</td>
<td>127.0.0.1</td>
<td><code>/PM_TEST_01/archive</code></td>
<td><span class="status-badge status-badge-danger" style="cursor: pointer;">강제퇴장</span></td>
</tr>
</tbody>
</table>
</div>
</section>
<section id="screen-project">
<h3>🏗️ 화면 2: 프로젝트 관리 (Project Management)</h3>
<p>좌측 프로젝트 목록 그리드와 우측 프로젝트별 참여자 및 배정 통제 그리드 구조입니다.</p>
<div class="spec-card">
<h4>① 프로젝트 목록 테이블 사양 (좌측 카드)</h4>
<p>출력 컬럼: <code>NO</code> | <code>프로젝트 ID</code> | <code>현장명</code> | <code>카테고리</code> | <code>용량 제한(GB)</code> | <code>상태</code> | <code>관리(수정/삭제)</code></p>
<ul>
<li>행 클릭 시, 우측의 '참여 권한 사용자 목록'이 해당 프로젝트 정보로 자동 리바인딩됩니다.</li>
<li><strong>삭제 제한</strong>: 관련 테이블(tb_data, tb_official_doc_file, tb_banner_notice 등)에 현장 ID 사용 이력이 있으면 삭제 불가능하며 경고 메시지가 발생합니다.</li>
<li><strong>신규 프로젝트 등록 및 수정 모달 (projectModalOverlay)</strong>: 프로젝트 ID(수정 시 Readonly), 프로젝트명, 단축명, 카테고리 Select, 스토리지 제한(GB), 상태를 편집합니다.</li>
</ul>
<h4>② 참여 권한 사용자 목록 (우측 카드 - 병합 영역)</h4>
<p>출력 컬럼: <code>NO</code> | <code>사용자 ID</code> | <code>이름</code> | <code>부서/직급</code> | <code>권한 등급</code> | <code>작업(배정제외)</code></p>
<ul>
<li><strong>권한 등급 변경</strong>: 인라인 셀렉터(Admin, Sub-Master, Worker, Viewer)로 권한 레벨 즉시 업데이트.</li>
<li><strong>사용자 배정 추가 팝업 모달 (assignModalOverlay)</strong>: 현재 현장에 미배정된 사용자들을 체크박스로 다중 선택하여 일괄 추가합니다. 또한 목록 선택식을 지원하기 위해 우측 하단 배정 대기 목록에서도 '즉시 배정' 단축 버튼을 제공합니다.</li>
</ul>
</div>
</section>
<section id="screen-banner">
<h3>📢 화면 3: 실시간 배너 공지 (Banner Notice)</h3>
<p>상단 배너 등록 폼 카드 및 하단 이력 검색 조건 필터와 이력 목록 그리드 구조입니다.</p>
<div class="spec-card">
<h4>① 배너 공지 등록 폼</h4>
<ul>
<li>입력 필드: 대상 프로젝트 선택(특정 현장 또는 전체 현장 'all' 매핑), 등록일(임의 편집 지원), 시작일, 종료일, 공지 자막 텍스트.</li>
<li>송출 등록 제출 시, 오늘 일자와 비교하여 즉시 이력에 추가되고 상태 배지가 실시간 부여됩니다.</li>
</ul>
<h4>② 이력 목록 필터 및 이력 테이블</h4>
<ul>
<li><strong>검색 필터</strong>: 송출 상태(전체, 송출중, 예약됨, 만료) 및 등록일(from ~ to) 날짜 범위 지정 검색.</li>
<li><strong>테이블 명세</strong>: <code>NO</code> | <code>등록일</code> | <code>대상 프로젝트</code> | <code>공지 내용</code> | <code>시작일</code> | <code>종료일</code> | <code>송출 상태</code> | <code>작업</code></li>
<li><strong>송출 중지 통제</strong>: 아직 기간이 유효한 행(송출중, 예약됨)에만 <code>[송출 중지]</code> 버튼이 노출 및 활성화되며, 이미 만료된 이력은 <code>[중지 완료]</code> 비활성 텍스트로 대체하여 이중 제어를 차단합니다.</li>
</ul>
</div>
</section>
<section id="screen-user">
<h3>👥 화면 4: 사용자 관리 (User Management)</h3>
<p>좌측 사용자 마스터 리스트 및 우측 선택된 사용자의 참여 권한 프로젝트 리스트업 구조입니다.</p>
<div class="spec-card">
<h4>① 사용자 계정 목록 (좌측 카드)</h4>
<p>출력 컬럼: <code>NO</code> | <code>아이디</code> | <code>이름</code> | <code>소속/직급</code> | <code>그룹</code> | <code>상태</code> | <code>관리(수정/삭제)</code></p>
<ul>
<li>행 클릭 시, 해당 사용자가 참여하고 있는 프로젝트 리스트가 우측 카드에 즉시 바인딩됩니다.</li>
<li><strong>삭제 제한</strong>: 권한 테이블(tb_permission)에 현장 배정/참여 권한 정보가 등록되어 있으면 삭제가 불가능하며 경고 메시지가 발생합니다.</li>
<li><strong>사용자 등록 및 수정 모달 (userModalOverlay)</strong>: 아이디(수정 시 Readonly), 패스워드, 이름, 회사명, 부서, 직급, 권한 그룹 지정 select, 재직 상태(재직/퇴직잠금) 지정.</li>
</ul>
<h4>② 권한부여 프로젝트 목록 (우측 카드)</h4>
<p>출력 컬럼: <code>NO</code> | <code>프로젝트 ID</code> | <code>프로젝트명</code> | <code>부여 권한 등급</code></p>
</div>
</section>
<section id="screen-audit">
<h3>🔎 화면 5: 감사 로그 조회 (Audit Logs)</h3>
<p>파일 조작 중요 이벤트(삭제, 이동, 다운로드) 목록 및 검색 조회 화면입니다.</p>
<div class="spec-card">
<h4>① 감사 로그 목록 사양</h4>
<p>출력 컬럼: <code>NO</code> | <code>일시</code> | <code>프로젝트</code> | <code>사용자 ID</code> | <code>접속 IP</code> | <code>조작 액션</code> | <code>조작 대상 경로(코드박스 스타일)</code></p>
<ul>
<li>필터링 항목: 사용자 ID 검색 입력란, 조작 액션 Dropdown, 검색 기능.</li>
</ul>
</div>
</section>
<section id="screen-policy">
<h3>⚙️ 화면 6: 자동 보관 및 파일 삭제 정책 설정 (Delete Policy)</h3>
<p>시스템 글로벌 일괄 정책 설정 영역, 실시간 예정 시나리오 요약, 그리고 배치 처리 이력 구조입니다.</p>
<div class="spec-card">
<h4>① 시스템 공통 자동 삭제 정책 설정 폼 [글로벌 정책 공통화]</h4>
<ul>
<li><strong>입력 필드</strong>: 정책 활성화 여부(Toggle/Select), 최소 유지 파일 개수 기준(숫자 입력), 자동 삭제 제한 기한(일) (숫자 입력).</li>
<li><strong>글로벌 통합</strong>: 기존의 프로젝트 개별 Dropdown은 완전히 배제하고, 전체 현장에 동일하게 일괄 반영합니다.</li>
</ul>
<h4>② 보존 정책 실시간 요약 (Dynamic Summary)</h4>
<ul>
<li>폼의 입력값을 변경하는 즉시 요약 영역 텍스트가 시나리오 문구로 동적 조합되어 나타납니다.</li>
<li>예: <code>"현재 전체 공통 설정에 따라, 각 현장의 보관 파일 수가 100개 미만이고 30일이 지나면 자동 삭제 배치 스케줄러가 작동합니다."</code></li>
</ul>
<h4>③ 자동 삭제 처리 이력 테이블 사양</h4>
<p>출력 컬럼: <code>NO</code> | <code>자동 처리 일자</code> | <code>프로젝트 ID</code> | <code>삭제 처리 폴더 경로</code> | <code>적용 기준</code> | <code>처리 결과(성공 배지)</code></p>
<ul>
<li>정책 값 저장 완료 시 이력 로그의 대상 프로젝트 ID 자리에는 <code>'SYSTEM'</code>이 기입됩니다.</li>
</ul>
</div>
</section>
<section id="screen-codes">
<h3>🔑 화면 7: 공통 코드 관리 (Common Code Management)</h3>
<p>대분류 마스터 및 세부 코드 리스트가 배치되는 상하 2단 수직 정렬 레이아웃 구조입니다.</p>
<div class="spec-card">
<h4>① 대분류 코드 마스터 (상단 카드)</h4>
<p>출력 컬럼: <code>NO</code> | <code>대분류 코드</code> | <code>대분류 코드명</code> | <code>사용</code> | <code>관리(수정/삭제)</code></p>
<ul>
<li>행을 선택(click)하면 해당 행이 하이라이트(selected)되며, 하단의 세부 코드 그리드가 동적으로 새로고침됩니다.</li>
<li><strong>대분류 등록 및 수정 모달 (codeMasterModalOverlay)</strong>: 대분류 코드, 명칭, 사용여부, 비고 설명 입력.</li>
</ul>
<h4>② 세부 소분류 코드 목록 (하단 카드)</h4>
<p>출력 컬럼: <code>NO</code> | <code>소분류 코드</code> | <code>조합 코드 (base_code)</code> | <code>코드 명칭</code> | <code>정렬 순서</code> | <code>사용</code> | <code>관리(수정/삭제)</code></p>
<ul>
<li><strong>유효성 방어 차단</strong>: 상단 대분류 테이블에서 행을 클릭하여 선택하지 않은 상태에서는 하단의 <code>[ 세부코드 등록]</code> 버튼이 강제 비활성화(disabled)되며, 팝업 접근 시 안내 팝업 및 경고 텍스트(<code>"상단에서 대분류 코드를 선택해 주세요."</code>)를 노출합니다.</li>
<li><strong>조합 코드(base_code)</strong>: 소분류 생성 완료 제출 시, <code>대분류코드_소분류코드</code> 형태로 자동 결합되어 저장됩니다.</li>
<li><strong>삭제 제한 (RESTRICT)</strong>: 마스터 대분류 코드를 삭제할 경우, 하위 세부 코드(code_detail)가 존재하면 대분류 삭제가 차단되고 경고 메시지를 노출합니다. (세부 코드가 먼저 삭제되어 비어있을 때만 대분류 삭제 가능)</li>
</ul>
</div>
</section>
</main>
</body>
</html>

View File

@@ -615,34 +615,63 @@ exports.deleteUser = async (req, res) => {
// 5. 활동 로그 조회 (Activity Logs) // 5. 활동 로그 조회 (Activity Logs)
exports.getAuditLogs = async (req, res) => { exports.getAuditLogs = async (req, res) => {
const { user_id, activity, project_nm } = req.query; let { user_id, activity, project_nm, page = 1, limit = 20 } = req.query;
page = parseInt(page);
limit = parseInt(limit);
const offset = (page - 1) * limit;
const client = await pool.connect(); const client = await pool.connect();
try { try {
let countQuery = `
SELECT COUNT(*) as total
FROM ver4.${tbLog} l
LEFT JOIN ver4.${tbProject} p ON l.project_id = p.project_id
WHERE 1=1
`;
let query = ` let query = `
SELECT l.log_id, l.log_date as clean_date, l.project_id, p.project_nm, l.user_id, l.user_ip, l.activity as clean_path, l.path_arr as criteria_info SELECT l.log_id, l.log_date as clean_date, l.project_id, p.project_nm, l.user_id, l.user_ip, l.activity as clean_path, l.path_arr as criteria_info
FROM ver4.${tbLog} l FROM ver4.${tbLog} l
LEFT JOIN ver4.${tbProject} p ON l.project_id = p.project_id LEFT JOIN ver4.${tbProject} p ON l.project_id = p.project_id
WHERE 1=1 WHERE 1=1
`; `;
const params = []; const filterParams = [];
let paramIndex = 1; let paramIndex = 1;
let filterSql = '';
if (user_id) { if (user_id) {
query += ` AND l.user_id ILIKE $${paramIndex++}`; filterSql += ` AND l.user_id ILIKE $${paramIndex++}`;
params.push(`%${user_id}%`); filterParams.push(`%${user_id}%`);
} }
if (project_nm) { if (project_nm) {
query += ` AND (p.project_nm ILIKE $${paramIndex++} OR l.project_id ILIKE $${paramIndex - 1})`; filterSql += ` AND (p.project_nm ILIKE $${paramIndex++} OR l.project_id ILIKE $${paramIndex - 1})`;
params.push(`%${project_nm}%`); filterParams.push(`%${project_nm}%`);
} }
if (activity && activity !== 'all') { if (activity && activity !== 'all') {
query += ` AND l.activity ILIKE $${paramIndex++}`; filterSql += ` AND l.activity ILIKE $${paramIndex++}`;
params.push(`%${activity}%`); filterParams.push(`%${activity}%`);
} }
query += ` ORDER BY l.log_id DESC LIMIT 100;`; countQuery += filterSql;
const result = await client.query(query, params); query += filterSql;
res.status(200).json(result.rows);
const countResult = await client.query(countQuery, filterParams);
const total = parseInt(countResult.rows[0].total);
query += ` ORDER BY l.log_id DESC LIMIT $${paramIndex++} OFFSET $${paramIndex++};`;
const dataParams = [...filterParams, limit, offset];
const result = await client.query(query, dataParams);
res.status(200).json({
data: result.rows,
pagination: {
total,
page,
limit,
totalPages: Math.ceil(total / limit)
}
});
} catch (err) { } catch (err) {
console.error("getAuditLogs Error:", err); console.error("getAuditLogs Error:", err);
res.status(500).json({ error: "활동 로그 조회 실패" }); res.status(500).json({ error: "활동 로그 조회 실패" });

View File

@@ -795,148 +795,15 @@ async function insertData(params) {
} }
async function insertLog(params, from) { async function insertLog(params, from) {
let { projectId, activity, userInfoString, userIp, resourcePathArr, dataIdArr, isExpiredFolder } = params; // Synchronous database inserts for activity log are now handled asynchronously
// by the activityLogger middleware via BullMQ.
if (from) { return { message: 'insertLog_success' };
console.log();
console.log(`=======================================`);
console.log(`${from}에서 insertLog 실행 (${makePostgresTimestamp()})`);
console.log(userInfoString);
console.log('isExpiredFolder: ', isExpiredFolder);
console.log(`=======================================`);
console.log();
}
if (activity == 'removeTarget_folder' && isExpiredFolder) activity = `${activity}_expired`
let userInfo = JSON.parse(userInfoString);
let userId = userInfo.user_id;
let dateNow = makePostgresTimestamp(Date.now());
const client = await pool.connect();
try {
let parsedResourcePathArr = resourcePathArr;
if (typeof resourcePathArr === 'string') {
try {
parsedResourcePathArr = JSON.parse(resourcePathArr);
} catch(e) {}
}
if (!Array.isArray(parsedResourcePathArr)) {
parsedResourcePathArr = [parsedResourcePathArr];
}
let parsedDataIdArr = dataIdArr;
if (typeof dataIdArr === 'string') {
try {
parsedDataIdArr = JSON.parse(dataIdArr);
} catch(e) {}
}
if (!Array.isArray(parsedDataIdArr)) {
parsedDataIdArr = [parsedDataIdArr];
}
let values = [
projectId,
activity,
userId,
userIp,
dateNow,
parsedResourcePathArr,
parsedDataIdArr
];
let queryString = `
INSERT INTO ver4.${tbLog} (
project_id, activity, user_id, user_ip, log_date, path_arr, data_id_arr
)
VALUES (
$1, $2, $3, $4, $5, $6, $7
);
`;
// console.log('=================');
// console.log(queryString);
await client.query(queryString, values);
let result = { message: 'insertLog_success' };
return result;
} catch(error) {
console.error("insertLog err:", error);
return { message: 'insertLog_failed', error: error };
} finally {
client.release();
}
// console.log('============================');
// console.log(projectId);
// console.log(activity);
// console.log(userId);
// console.log(userIp);
// console.log(resourcePathArr);
// console.log(dataIdArr);
} }
async function insertClickLog(params) { async function insertClickLog(params) {
let { projectId, activity, userInfoString, userIp, resourcePathArr, dataIdArr } = params; // Synchronous database inserts for click log are now handled asynchronously
let userInfo = JSON.parse(userInfoString); // by the activityLogger middleware via BullMQ.
let userId = userInfo.user_id; return { message: 'insertClickLog_success' };
let dateNow = makePostgresTimestamp(Date.now());
const client = await pool.connect();
try {
let parsedResourcePathArr = resourcePathArr;
if (typeof resourcePathArr === 'string') {
try {
parsedResourcePathArr = JSON.parse(resourcePathArr);
} catch(e) {}
}
if (!Array.isArray(parsedResourcePathArr)) {
parsedResourcePathArr = [parsedResourcePathArr];
}
let parsedDataIdArr = dataIdArr;
if (typeof dataIdArr === 'string') {
try {
parsedDataIdArr = JSON.parse(dataIdArr);
} catch(e) {}
}
if (!Array.isArray(parsedDataIdArr)) {
parsedDataIdArr = [parsedDataIdArr];
}
let values = [
projectId,
activity,
userId,
userIp,
dateNow,
parsedResourcePathArr,
parsedDataIdArr
];
let queryString = `
INSERT INTO ver4.${tbClickLog} (
project_id, activity, user_id, user_ip, log_date, path_arr, data_id_arr
)
VALUES (
$1, $2, $3, $4, $5, $6, $7
);
`;
// console.log('=================');
// console.log(queryString);
await client.query(queryString, values);
let result = { message: 'insertClickLog_success' };
return result;
} catch(error) {
console.error("insertClickLog err:", error);
} finally {
client.release();
}
} }
/* /*

View File

@@ -13,6 +13,7 @@ const convertQueue3 = new Queue('zip-folder', { connection: redisConnection });
const convertQueue4 = new Queue('post-process-video', { connection: redisConnection }); const convertQueue4 = new Queue('post-process-video', { connection: redisConnection });
const convertQueue5 = new Queue('ai-summarize', { connection: redisConnection }); const convertQueue5 = new Queue('ai-summarize', { connection: redisConnection });
const convertQueue6 = new Queue('api-summarize', {connection: redisConnection}); const convertQueue6 = new Queue('api-summarize', {connection: redisConnection});
const convertQueue7 = new Queue('activity-log', {connection: redisConnection});
// const convertQueue3 = new Queue('test-job', { connection: redisConnection }); // const convertQueue3 = new Queue('test-job', { connection: redisConnection });
// 2) ExpressAdapter 생성 및 기본 경로 설정 // 2) ExpressAdapter 생성 및 기본 경로 설정
@@ -28,6 +29,7 @@ createBullBoard({
new BullMQAdapter(convertQueue4), new BullMQAdapter(convertQueue4),
new BullMQAdapter(convertQueue5), new BullMQAdapter(convertQueue5),
new BullMQAdapter(convertQueue6), new BullMQAdapter(convertQueue6),
new BullMQAdapter(convertQueue7),
// new BullMQAdapter(convertQueue3), // new BullMQAdapter(convertQueue3),
// 여기에 더 필요한 큐 어댑터를 추가할 수 있습니다. // 여기에 더 필요한 큐 어댑터를 추가할 수 있습니다.
], ],

View File

@@ -43,7 +43,14 @@ exports.getProject = async (req,res,next)=>{
try { try {
// 테스트 // 테스트
let queryString = ` let queryString = `
select p.*, u.* select p.*, u.*,
(SELECT notice_text
FROM ver4.tb_banner_notice b
WHERE (b.project_id = p.project_id OR b.project_id IS NULL)
AND b.status_code = 'NOTICE_STATUS_active'
AND CURRENT_DATE BETWEEN b.start_date AND b.end_date
ORDER BY b.banner_id DESC
LIMIT 1) as banner_notice
from ver4.${tbProject} p from ver4.${tbProject} p
inner join ver4.tb_user u inner join ver4.tb_user u
on p.user_id = u.user_id on p.user_id = u.user_id

View File

@@ -1,6 +1,8 @@
const pool = require("./db/pool.js"); const pool = require("./db/pool.js");
async function runPatch() { async function runPatch() {
const env = process.env.NODE_ENV;
const tbProject = env === 'production' ? 'tb_project' : '_test_tb_project';
const client = await pool.connect(); const client = await pool.connect();
try { try {
console.log("🚀 Starting Admin Dashboard DB Patch..."); console.log("🚀 Starting Admin Dashboard DB Patch...");
@@ -49,7 +51,7 @@ async function runPatch() {
await client.query(` await client.query(`
CREATE TABLE IF NOT EXISTS ver4.tb_banner_notice ( CREATE TABLE IF NOT EXISTS ver4.tb_banner_notice (
banner_id SERIAL PRIMARY KEY, banner_id SERIAL PRIMARY KEY,
project_id VARCHAR(50) REFERENCES ver4.tb_project(project_id), project_id VARCHAR(50),
reg_date DATE DEFAULT CURRENT_DATE, reg_date DATE DEFAULT CURRENT_DATE,
start_date DATE NOT NULL, start_date DATE NOT NULL,
end_date DATE NOT NULL, end_date DATE NOT NULL,
@@ -58,6 +60,18 @@ async function runPatch() {
); );
`); `);
// 외래키 제약조건을 환경(env)에 맞춰 동적으로 설정 (기존 제약조건이 다르게 걸려있으면 재설정)
await client.query(`
ALTER TABLE ver4.tb_banner_notice
DROP CONSTRAINT IF EXISTS tb_banner_notice_project_id_fkey
`);
await client.query(`
ALTER TABLE ver4.tb_banner_notice
ADD CONSTRAINT tb_banner_notice_project_id_fkey
FOREIGN KEY (project_id) REFERENCES ver4.${tbProject}(project_id) ON DELETE CASCADE
`);
// 5. tb_auto_clean_log 테이블 생성 // 5. tb_auto_clean_log 테이블 생성
console.log("Creating ver4.tb_auto_clean_log table..."); console.log("Creating ver4.tb_auto_clean_log table...");
await client.query(` await client.query(`

43
db_patch_v4_log.js Normal file
View File

@@ -0,0 +1,43 @@
const pool = require("./db/pool.js");
async function runPatch() {
const client = await pool.connect();
try {
console.log("🚀 Starting DB Patch for Activity Log (tb_log)...");
// 1. tb_log 테이블 컬럼 추가
console.log("Adding columns to ver4.tb_log...");
await client.query(`
ALTER TABLE ver4.tb_log
ADD COLUMN IF NOT EXISTS status VARCHAR(20) DEFAULT 'SUCCESS',
ADD COLUMN IF NOT EXISTS meta_data JSONB;
`);
// 2. _test_tb_log 테이블 컬럼 추가
console.log("Adding columns to ver4._test_tb_log...");
await client.query(`
ALTER TABLE ver4._test_tb_log
ADD COLUMN IF NOT EXISTS status VARCHAR(20) DEFAULT 'SUCCESS',
ADD COLUMN IF NOT EXISTS meta_data JSONB;
`);
// 3. 인덱스 최적화
console.log("Creating indexes for activity log tables...");
await client.query(`
CREATE INDEX IF NOT EXISTS idx_tb_log_project_date ON ver4.tb_log (project_id, log_date DESC);
CREATE INDEX IF NOT EXISTS idx_tb_log_user_date ON ver4.tb_log (user_id, log_date DESC);
CREATE INDEX IF NOT EXISTS idx_test_tb_log_project_date ON ver4._test_tb_log (project_id, log_date DESC);
CREATE INDEX IF NOT EXISTS idx_test_tb_log_user_date ON ver4._test_tb_log (user_id, log_date DESC);
`);
console.log("🎉 DB Patch for Activity Log completed successfully!");
} catch (err) {
console.error("❌ DB Patch Error:", err);
} finally {
client.release();
await pool.end();
}
}
runPatch();

BIN
downloaded_test.hwpx Normal file

Binary file not shown.

Binary file not shown.

2
libs/wmf.js Normal file

File diff suppressed because one or more lines are too long

View File

@@ -39,6 +39,11 @@
"date": 1781740205874, "date": 1781740205874,
"name": "D:\\40. 개발소스\\04. PM\\pm_ver4\\trunk\\PM_ver4\\logs\\2026-06-18.exception.log", "name": "D:\\40. 개발소스\\04. PM\\pm_ver4\\trunk\\PM_ver4\\logs\\2026-06-18.exception.log",
"hash": "b6c375bb37b791ea2408e99580d68d5582d2e051ea204bc0529f30d0d3452da6" "hash": "b6c375bb37b791ea2408e99580d68d5582d2e051ea204bc0529f30d0d3452da6"
},
{
"date": 1781826376885,
"name": "D:\\40. 개발소스\\04. PM\\pm_ver4\\trunk\\PM_ver4\\logs\\2026-06-19.exception.log",
"hash": "9dc3f5a0ca6cb76f233f9ffa9a7c095d01dc03bb7295be9b7bca9f9cdb1ca191"
} }
], ],
"hashType": "sha256" "hashType": "sha256"

View File

@@ -39,6 +39,11 @@
"date": 1781740205863, "date": 1781740205863,
"name": "D:\\40. 개발소스\\04. PM\\pm_ver4\\trunk\\PM_ver4\\logs\\2026-06-18.error.log", "name": "D:\\40. 개발소스\\04. PM\\pm_ver4\\trunk\\PM_ver4\\logs\\2026-06-18.error.log",
"hash": "c3ea07a16e0988461aee9271b7f5b0faabeadbf8df4f36e428e1e43c313c7738" "hash": "c3ea07a16e0988461aee9271b7f5b0faabeadbf8df4f36e428e1e43c313c7738"
},
{
"date": 1781826376883,
"name": "D:\\40. 개발소스\\04. PM\\pm_ver4\\trunk\\PM_ver4\\logs\\2026-06-19.error.log",
"hash": "31106fea524e9fea9db56be5b73bd9317b65d7a031a7d6dcf9586a6d9376ce38"
} }
], ],
"hashType": "sha256" "hashType": "sha256"

View File

@@ -0,0 +1,4 @@
2026-06-18 15:34:14 [error 테스트: ] error: uncaughtException: Cannot read properties of undefined (reading 'getWaitingCount')
TypeError: Cannot read properties of undefined (reading 'getWaitingCount')
at exports.convertPdf (D:\40. 개발소스\04. PM\pm_ver4\trunk\PM_ver4\controllers\archiveController.js:3144:46)
at process.processTicksAndRejections (node:internal/process/task_queues:104:5)

11
logs/2026-06-19.error.log Normal file
View File

@@ -0,0 +1,11 @@
2026-06-19 09:08:38 [error 테스트: ] error: null value in column "user_pw" of relation "tb_user" violates not-null constraint
2026-06-19 09:08:41 [error 테스트: ] error: null value in column "user_pw" of relation "tb_user" violates not-null constraint
2026-06-19 09:08:44 [error 테스트: ] error: null value in column "user_pw" of relation "tb_user" violates not-null constraint
2026-06-19 09:08:46 [error 테스트: ] error: null value in column "user_pw" of relation "tb_user" violates not-null constraint
2026-06-19 09:08:52 [error 테스트: ] error: null value in column "user_pw" of relation "tb_user" violates not-null constraint
2026-06-19 09:08:54 [error 테스트: ] error: null value in column "user_pw" of relation "tb_user" violates not-null constraint
2026-06-19 09:09:02 [error 테스트: ] error: null value in column "user_pw" of relation "tb_user" violates not-null constraint
2026-06-19 09:09:05 [error 테스트: ] error: null value in column "user_pw" of relation "tb_user" violates not-null constraint
2026-06-19 09:09:16 [error 테스트: ] error: null value in column "user_pw" of relation "tb_user" violates not-null constraint
2026-06-19 09:09:17 [error 테스트: ] error: null value in column "user_pw" of relation "tb_user" violates not-null constraint
2026-06-19 09:09:19 [error 테스트: ] error: null value in column "user_pw" of relation "tb_user" violates not-null constraint

View File

@@ -0,0 +1,4 @@
2026-06-19 09:25:33 [error 테스트: ] error: uncaughtException: Cannot read properties of undefined (reading 'getWaitingCount')
TypeError: Cannot read properties of undefined (reading 'getWaitingCount')
at exports.convertPdf (D:\40. 개발소스\04. PM\pm_ver4\trunk\PM_ver4\controllers\archiveController.js:3144:46)
at process.processTicksAndRejections (node:internal/process/task_queues:104:5)

View File

@@ -0,0 +1,70 @@
const { addActivityLogJob } = require('../queue');
exports.activityLogger = (defaultAction) => {
return (req, res, next) => {
res.on('finish', () => {
if (res.statusCode >= 200 && res.statusCode < 300) {
try {
const params = req.body?.params || {};
const projectId = params.projectId || req.baseUrl.split('/')[1] || '-';
const activity = params.activity || defaultAction || '-';
let userId = 'system';
if (params.userInfoString) {
try {
const userInfo = JSON.parse(params.userInfoString);
userId = userInfo.user_id || 'system';
} catch (e) {}
} else if (req.user?.user_id) {
userId = req.user.user_id;
}
const userIp = req.ip || req.headers['x-forwarded-for'] || req.socket.remoteAddress;
let targetPath = '-';
if (params.resourcePath) {
targetPath = params.resourcePath;
} else if (Array.isArray(params.resourcePathArr) && params.resourcePathArr.length > 0) {
targetPath = params.resourcePathArr[0];
} else if (Array.isArray(params.fromPathArr) && params.fromPathArr.length > 0) {
targetPath = params.fromPathArr[0];
}
let dataId = null;
if (params.dataId) {
dataId = params.dataId;
} else if (Array.isArray(params.dataIdArr) && params.dataIdArr.length > 0) {
dataId = params.dataIdArr[0];
}
const logData = {
projectId,
activity,
userId,
userIp,
targetPath,
status: 'SUCCESS',
logDate: new Date(),
metaData: {
method: req.method,
url: req.originalUrl,
dataIdArr: params.dataIdArr || (dataId ? [dataId] : []),
dataType: params.dataType || '-',
newName: params.newName || '-',
oldName: params.oldName || '-'
}
};
addActivityLogJob(logData).catch(err => {
console.error("[ActivityLogger Middleware] Queue Error:", err);
});
} catch (err) {
console.error("[ActivityLogger Middleware] Intercept Error:", err);
}
}
});
next();
};
};

View File

@@ -2,7 +2,6 @@ const { Queue, QueueEvents, Job, Worker } = require('bullmq');
const { redisConnection } = require('./config/redis.js'); const { redisConnection } = require('./config/redis.js');
const { getIo } = require('./socket'); const { getIo } = require('./socket');
const axios = require('axios'); const axios = require('axios');
const controllers = require(`./controllers/archiveController.js`);
const { GoogleGenerativeAI } = require('@google/generative-ai'); const { GoogleGenerativeAI } = require('@google/generative-ai');
const pool = require('./db/pool.js'); const pool = require('./db/pool.js');
const { GetObjectCommand } = require('@aws-sdk/client-s3'); const { GetObjectCommand } = require('@aws-sdk/client-s3');
@@ -182,7 +181,7 @@ summarizeAIQueueEvents.on('completed', async({ jobId, returnvalue }) => {
type: type type: type
}; };
await controllers.addSummarizeAiLog(addSummarizeAiLogParams); await require('./controllers/archiveController.js').addSummarizeAiLog(addSummarizeAiLogParams);
} }
}); });
@@ -266,7 +265,7 @@ summarizeAPIQueueEvents.on('completed', async({ jobId, returnvalue }) => {
isState: true isState: true
}; };
await controllers.addSummarizeAiLog(addSummarizeAiLogParams); await require('./controllers/archiveController.js').addSummarizeAiLog(addSummarizeAiLogParams);
} }
}); });
@@ -515,5 +514,73 @@ const summarizeAPIWorker = new Worker('api-summarize', async (job) => {
lockDuration: 90000 // lock 유지 시간을 90초로 늘려 stalled 에러 방지 lockDuration: 90000 // lock 유지 시간을 90초로 늘려 stalled 에러 방지
}); });
module.exports = { convertPdfQueue, zipFolderQueue, thumbQueue, postProcessVideoQueue, summarizeAIQueue, summarizeAPIQueue }; // 🔻🔻🔻🔻🔻🔻🔻🔻 worker7 활동로그 비동기 적재 관련 내용 시작 🔻🔻🔻🔻🔻🔻🔻🔻
const activityLogQueue = new Queue('activity-log', { connection: redisConnection });
function makePostgresTimestamp(date) {
if (date) date = new Date(date);
else date = new Date(Date.now());
// Intl API로 현재 시스템 타임존 확인
const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone;
// KST 변환이 필요한 경우에만 UTC + 9시간 적용
const isUtcOrNonSeoul = !timeZone || timeZone !== 'Asia/Seoul';
date = isUtcOrNonSeoul ? new Date(date.getTime() + 9 * 60 * 60 * 1000) : date;
let YYYY = date.getFullYear();
let MM = String(date.getMonth() + 1).padStart(2, '0');
let DD = String(date.getDate()).padStart(2, '0');
let HH = String(date.getHours()).padStart(2, '0');
let mm = String(date.getMinutes()).padStart(2, '0');
let ss = String(date.getSeconds()).padStart(2, '0');
let SSS = String(date.getMilliseconds()).padStart(3, '0');
return `${YYYY}-${MM}-${DD} ${HH}:${mm}:${ss}.${SSS}`;
}
async function addActivityLogJob(logData) {
await activityLogQueue.add('insert-log', logData, {
removeOnComplete: 100, // 완료된 작업 보관 개수 제한
attempts: 3, // DB 지연 시 최대 3회 재시도
backoff: 5000 // 재시도 간격 5초
});
}
const activityLogWorker = new Worker('activity-log', async (job) => {
const { projectId, activity, userId, userIp, targetPath, status, logDate, metaData } = job.data;
const client = await pool.connect();
try {
const env = process.env.NODE_ENV;
const tbLog = env === 'production' ? 'tb_log' : '_test_tb_log';
const formattedLogDate = makePostgresTimestamp(logDate);
await client.query(`
INSERT INTO ver4.${tbLog} (project_id, activity, user_id, user_ip, log_date, path_arr, status, meta_data)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
`, [projectId, activity, userId, userIp, formattedLogDate, [targetPath], status, JSON.stringify(metaData)]);
} catch (err) {
console.error("Failed to write activity log to DB:", err);
throw err; // triggers BullMQ retry logic
} finally {
client.release();
}
}, {
connection: redisConnection,
lockDuration: 30000
});
// 🔺🔺🔺🔺🔺🔺🔺🔺 worker7 활동로그 관련 내용 끝 🔺🔺🔺🔺🔺🔺🔺🔺
module.exports = {
convertPdfQueue,
zipFolderQueue,
thumbQueue,
postProcessVideoQueue,
summarizeAIQueue,
summarizeAPIQueue,
activityLogQueue,
addActivityLogJob
};

View File

@@ -2,6 +2,7 @@ const express = require('express');
const router = express.Router({mergeParams:true}); const router = express.Router({mergeParams:true});
const path = require('path'); const path = require('path');
const { isLoggedIn } = require('../oauth/oauthController'); const { isLoggedIn } = require('../oauth/oauthController');
const { activityLogger } = require('../middlewares/activityLogger');
const { const {
// setUseEncrypt, // setUseEncrypt,
@@ -77,22 +78,22 @@ router.get('/getRecycleBinObject', getRecycleBinObject);
router.get('/getFolderSize', getFolderSize); router.get('/getFolderSize', getFolderSize);
router.get('/getLog', getLog); router.get('/getLog', getLog);
router.post('/checkTargetExists', checkTargetExists); router.post('/checkTargetExists', checkTargetExists);
router.post('/createFolder', createFolder); router.post('/createFolder', activityLogger('createFolder'), createFolder);
router.post('/generateUploadUrl', generateUploadUrl); router.post('/generateUploadUrl', generateUploadUrl);
router.post('/uploadData', uploadData); router.post('/uploadData', activityLogger('uploadData'), uploadData);
router.post('/ensureAddOnFolder', ensureAddOnFolder); router.post('/ensureAddOnFolder', ensureAddOnFolder);
router.post('/renameTarget', renameTarget); router.post('/renameTarget', activityLogger('renameTarget'), renameTarget);
router.post('/editAuthor', editAuthor); router.post('/editAuthor', editAuthor);
router.post('/getDataInfo', getDataInfo); router.post('/getDataInfo', getDataInfo);
router.post('/generateDownloadUrl', generateDownloadUrl); router.post('/generateDownloadUrl', generateDownloadUrl);
router.post('/downloadTarget', downloadTarget); router.post('/downloadTarget', activityLogger('downloadTarget'), downloadTarget);
router.post('/relocateTarget', relocateTarget); router.post('/relocateTarget', activityLogger('relocateTarget'), relocateTarget);
router.post('/removeTarget', removeTarget); router.post('/removeTarget', activityLogger('removeTarget'), removeTarget);
router.post('/deleteTarget', deleteTarget); router.post('/deleteTarget', activityLogger('deleteTarget'), deleteTarget);
router.post('/setDataPermission', setDataPermission); router.post('/setDataPermission', setDataPermission);
router.post('/editPosition', editPosition); router.post('/editPosition', editPosition);
router.post('/renewExpiryDate', renewExpiryDate); router.post('/renewExpiryDate', renewExpiryDate);
router.post('/convertPdf', convertPdf); router.post('/convertPdf', activityLogger('convertPdf'), convertPdf);
router.post('/makeThumbPdf', makeThumbPdf); router.post('/makeThumbPdf', makeThumbPdf);
router.post('/addConvetPdfLog', addConvetPdfLog); router.post('/addConvetPdfLog', addConvetPdfLog);
router.post('/removeConvertingData', removeConvertingData); router.post('/removeConvertingData', removeConvertingData);
@@ -100,24 +101,24 @@ router.post('/postProcessVideo', postProcessVideo);
router.post('/requestResetViewer', requestResetViewer); router.post('/requestResetViewer', requestResetViewer);
router.post('/updateMemoInfo', updateMemoInfo); router.post('/updateMemoInfo', updateMemoInfo);
router.get('/getMemoInfo', getMemoInfo); router.get('/getMemoInfo', getMemoInfo);
router.post('/uploadData_titleImg', uploadData_titleImg); router.post('/uploadData_titleImg', activityLogger('uploadData_titleImg'), uploadData_titleImg);
router.get('/generateImageUrl', generateImageUrl); router.get('/generateImageUrl', generateImageUrl);
router.get('/isMainTitleImage', isMainTitleImage); router.get('/isMainTitleImage', isMainTitleImage);
router.post('/deleteMainTitleImage', deleteMainTitleImage); router.post('/deleteMainTitleImage', deleteMainTitleImage);
router.post('/mgmtFunc_resetConvert', mgmtFunc_resetConvert); router.post('/mgmtFunc_resetConvert', mgmtFunc_resetConvert);
router.post('/mgmtFunc_addClickLog', mgmtFunc_addClickLog); router.post('/mgmtFunc_addClickLog', activityLogger('clickTarget'), mgmtFunc_addClickLog);
router.get('/getControlBoxPosition', getControlBoxPosition); router.get('/getControlBoxPosition', getControlBoxPosition);
router.post('/setControlBoxPosition', setControlBoxPosition); router.post('/setControlBoxPosition', setControlBoxPosition);
router.post('/summarizeAI', summarizeAI.fields([ router.post('/summarizeAI', summarizeAI.fields([
{ name: 'prompt_file', maxCount: 1 }, { name: 'prompt_file', maxCount: 1 },
{ name: 'schema_file', maxCount: 1 }, { name: 'schema_file', maxCount: 1 },
]), summarizeAI_action); ]), summarizeAI_action);
router.post('/addSummarizeAiLog', addSummarizeAiLog); router.post('/addSummarizeAiLog', activityLogger('summarizeAI'), addSummarizeAiLog);
router.get('/downloadzip', downloadzip); router.get('/downloadzip', downloadzip);
router.get('/summarizeState', summarizeState) router.get('/summarizeState', summarizeState)
router.get('/summarizeState', summarizeState) router.get('/summarizeState', summarizeState)
router.get('/getMyDownloadList', getMyDownloadList); router.get('/getMyDownloadList', getMyDownloadList);
router.post('/addPermissionLog', addPermissionLog); router.post('/addPermissionLog', activityLogger('addPermission'), addPermissionLog);
router.get('/getNullThumbnailDataInfo', getNullThumbnailDataInfo); router.get('/getNullThumbnailDataInfo', getNullThumbnailDataInfo);
router.post('/updateThumbnailInfo', updateThumbnailInfo); router.post('/updateThumbnailInfo', updateThumbnailInfo);

14
scratch_check_header.js Normal file
View File

@@ -0,0 +1,14 @@
const fs = require('fs');
const fileData = fs.readFileSync('downloaded_test.hwpx');
const hex = fileData.subarray(0, 16).toString('hex').toUpperCase();
console.log('Hex signature of first 16 bytes:');
console.log(hex.match(/.{1,2}/g).join(' '));
if (hex.startsWith('D0CF11E0A1B11AE1')) {
console.log('File type: OLE Compound File (standard HWP binary)');
} else if (hex.startsWith('504B0304')) {
console.log('File type: ZIP file (standard HWPX)');
} else {
console.log('File type: Unknown');
}

29
scratch_download_hwp.js Normal file
View File

@@ -0,0 +1,29 @@
const { GetObjectCommand } = require('@aws-sdk/client-s3');
const onPremiseClient = require('./config/onPremiseClient.js');
const fs = require('fs');
async function run() {
const bucketName = 'pm-test-04';
const objectKey = 'archive/origin/01_TEST01_산출물/A/2. 뷰잉 테스트/test_hwpx.hwpx__260616-103002-723';
console.log(`Downloading ${objectKey} from ${bucketName}...`);
try {
const command = new GetObjectCommand({
Bucket: bucketName,
Key: objectKey
});
const response = await onPremiseClient.send(command);
// Write to local file
const writeStream = fs.createWriteStream('downloaded_test.hwpx');
response.Body.pipe(writeStream);
writeStream.on('finish', () => {
console.log('Download finished. File saved as downloaded_test.hwpx');
});
} catch (err) {
console.error('Error downloading:', err);
}
}
run();

47
scratch_inspect_hwp.js Normal file
View File

@@ -0,0 +1,47 @@
const fs = require('fs');
const hwp = require('./libs/hwp.js');
function run() {
console.log("Parsing downloaded_test.hwpx...");
try {
const fileBuffer = fs.readFileSync('downloaded_test.hwpx');
const doc = hwp.parse(fileBuffer, { type: 'buffer' });
console.log("\nDocument parsed successfully.");
console.log(`Number of sections: ${doc.sections.length}`);
doc.sections.forEach((section, sIdx) => {
console.log(`\nSection ${sIdx}:`);
console.log(` Page width: ${section.width} (${section.width / 7200} in)`);
console.log(` Page height: ${section.height} (${section.height / 7200} in)`);
console.log(` PaddingTop: ${section.paddingTop}`);
console.log(` HeaderPadding: ${section.headerPadding}`);
console.log(` PaddingLeft: ${section.paddingLeft}`);
let shapeCount = 0;
section.content.forEach((para, pIdx) => {
para.controls.forEach((ctrl, cIdx) => {
// Check if it's a shape (Rectangle, Ellipse, Line, etc.)
// Check properties
const isShape = ctrl.type !== 1347570004; // Not table
if (isShape) {
shapeCount++;
console.log(`\n --- Shape #${shapeCount} inside Paragraph ${pIdx} ---`);
console.log(` Control ID: ${ctrl.id} / Type: ${ctrl.type}`);
console.log(` Width: ${ctrl.width} (${ctrl.width / 100} pt)`);
console.log(` Height: ${ctrl.height} (${ctrl.height / 100} pt)`);
console.log(` vertRelTo: ${ctrl.attribute ? ctrl.attribute.vertRelTo : 'N/A'}`);
console.log(` verticalOffset: ${ctrl.verticalOffset} (${ctrl.verticalOffset / 100} pt)`);
console.log(` horizontalOffset: ${ctrl.horizontalOffset} (${ctrl.horizontalOffset / 100} pt)`);
console.log(` zIndex: ${ctrl.zIndex}`);
}
});
});
console.log(`\nTotal shapes in section ${sIdx}: ${shapeCount}`);
});
} catch (err) {
console.error("Error parsing HWP:", err);
}
}
run();

57
scratch_parse_hwpx.js Normal file
View File

@@ -0,0 +1,57 @@
const fs = require('fs');
const jszip = require('jszip');
async function run() {
try {
const fileData = fs.readFileSync('downloaded_test.hwpx');
const zip = await jszip.loadAsync(fileData);
// List files in the HWPX zip
console.log('Zip file entries:');
zip.forEach((relativePath, file) => {
console.log(' -', relativePath);
});
// Find section0.xml or similar XML files containing text content
const sectionFile = Object.keys(zip.files).find(name => name.includes('section') && name.endsWith('.xml'));
if (sectionFile) {
console.log(`\nReading ${sectionFile}...`);
const content = await zip.file(sectionFile).async('text');
// Search for shapes
// In HWPX, shapes are under <hp:rect>, <hp:ellipse>, <hp:line> etc.
// Let's write them to a local file for inspection
fs.writeFileSync('section0.xml', content);
console.log('Main section XML saved as section0.xml');
// Regex to find shape properties
// HWPX coordinates: vertRelTo, horzRelTo, vertAlign, horzAlign, vertOffset, horzOffset
console.log('\nSearching for shape properties in section XML:');
const shapeObjectRegex = /<hp:shapeObject[^>]*>([\s\S]*?)<\/hp:shapeObject>/g;
let match;
let count = 0;
while ((match = shapeObjectRegex.exec(content)) !== null) {
count++;
console.log(`\n--- Shape Object #${count} ---`);
const shapeObjectHeader = match[0].substring(0, match[0].indexOf('>') + 1);
console.log('Header:', shapeObjectHeader);
// Find vertRelTo, horzRelTo, etc.
const attrs = ['vertRelTo', 'horzRelTo', 'vertAlign', 'horzAlign', 'vertOffset', 'horzOffset'];
attrs.forEach(attr => {
const reg = new RegExp(`${attr}="([^"]*)"`);
const m = reg.exec(shapeObjectHeader);
if (m) {
console.log(` ${attr}: ${m[1]}`);
}
});
}
} else {
console.log('No section XML file found.');
}
} catch (err) {
console.error('Error parsing HWPX:', err);
}
}
run();

28
scratch_query_hwp.js Normal file
View File

@@ -0,0 +1,28 @@
const pool = require("./db/pool.js");
async function run() {
const client = await pool.connect();
try {
console.log("Querying HWP/HWPX files from database...");
const query = `
SELECT *
FROM ver4._test_tb_data
WHERE ext IN ('hwp', 'hwpx')
ORDER BY data_id DESC
LIMIT 5;
`;
const res = await client.query(query);
console.log("Results:");
res.rows.forEach(row => {
console.log(JSON.stringify(row, null, 2));
});
} catch (err) {
console.error("Error checking HWP files:", err);
} finally {
client.release();
await pool.end();
}
}
run();

View File

@@ -407,6 +407,46 @@
background-color: #ffffff; background-color: #ffffff;
} }
/* Pagination styling */
.pagination-container {
display: flex;
justify-content: center;
align-items: center;
gap: 6px;
margin-top: 20px;
padding-bottom: 10px;
}
.pagination-btn {
padding: 6px 12px;
border: 1px solid var(--border);
background-color: var(--card-bg);
color: var(--text-main);
border-radius: var(--radius-md);
cursor: pointer;
font-size: 0.85rem;
font-weight: 500;
transition: var(--transition);
}
.pagination-btn:hover:not(:disabled) {
border-color: var(--primary);
color: var(--primary);
background-color: var(--primary-soft);
}
.pagination-btn.active {
background-color: var(--primary);
color: #ffffff;
border-color: var(--primary);
}
.pagination-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
color: var(--text-light);
}
table.admin-table { table.admin-table {
width: 100%; width: 100%;
border-collapse: collapse; border-collapse: collapse;
@@ -814,7 +854,7 @@
<div class="card-header"> <div class="card-header">
<h3 class="card-title">📢 실시간 배너 공지사항 등록</h3> <h3 class="card-title">📢 실시간 배너 공지사항 등록</h3>
</div> </div>
<form class="config-form" style="gap: 20px;" onsubmit="handleBannerSubmit(event)"> <form class="config-form" style="gap: 20px;" onsubmit="submitBannerForm(event)">
<div class="form-row"> <div class="form-row">
<div class="form-group"> <div class="form-group">
<label for="banner-project-select">공지 대상 프로젝트</label> <label for="banner-project-select">공지 대상 프로젝트</label>
@@ -1030,6 +1070,8 @@
</tbody> </tbody>
</table> </table>
</div> </div>
<!-- Pagination Controls -->
<div id="audit-log-pagination" class="pagination-container"></div>
</div> </div>
</div> </div>
@@ -1428,6 +1470,20 @@
} }
} }
// 날짜 문자열을 클라이언트 로컬 타임존 기반의 YYYY-MM-DD HH:mm:ss 형식으로 변환하는 함수
function formatDate(dateStr) {
if (!dateStr) return '-';
const date = new Date(dateStr);
if (isNaN(date.getTime())) return dateStr;
const YYYY = date.getFullYear();
const MM = String(date.getMonth() + 1).padStart(2, '0');
const DD = String(date.getDate()).padStart(2, '0');
const HH = String(date.getHours()).padStart(2, '0');
const mm = String(date.getMinutes()).padStart(2, '0');
const ss = String(date.getSeconds()).padStart(2, '0');
return `${YYYY}-${MM}-${DD} ${HH}:${mm}:${ss}`;
}
// 로그인 사용자 프로필 로드 함수 // 로그인 사용자 프로필 로드 함수
async function loadUserProfile() { async function loadUserProfile() {
try { try {
@@ -2275,7 +2331,7 @@
} }
// --- 7. 활동 로그 조회 탭 (Activity Logs) --- // --- 7. 활동 로그 조회 탭 (Activity Logs) ---
async function renderAuditLogs() { async function renderAuditLogs(page = 1) {
const userId = document.getElementById('search-log-user').value.trim(); const userId = document.getElementById('search-log-user').value.trim();
const projectNm = document.getElementById('search-log-project').value.trim(); const projectNm = document.getElementById('search-log-project').value.trim();
const action = document.getElementById('filter-log-action').value.trim(); const action = document.getElementById('filter-log-action').value.trim();
@@ -2284,16 +2340,22 @@
if (userId) params.append('user_id', userId); if (userId) params.append('user_id', userId);
if (projectNm) params.append('project_nm', projectNm); if (projectNm) params.append('project_nm', projectNm);
if (action) params.append('activity', action); if (action) params.append('activity', action);
params.append('page', page);
params.append('limit', 20);
let queryUrl = `/api/admin/audit-logs?${params.toString()}`; let queryUrl = `/api/admin/audit-logs?${params.toString()}`;
try { try {
const logs = await fetchAPI(queryUrl); const resData = await fetchAPI(queryUrl);
const logs = resData.data;
const pagination = resData.pagination;
const body = document.getElementById('audit-log-body'); const body = document.getElementById('audit-log-body');
body.innerHTML = ''; body.innerHTML = '';
if (logs.length === 0) { if (!logs || logs.length === 0) {
body.innerHTML = `<tr><td colspan="7" style="text-align: center; color: var(--text-light); padding: 24px 0;">조회된 활동 로그 내역이 없습니다.</td></tr>`; body.innerHTML = `<tr><td colspan="7" style="text-align: center; color: var(--text-light); padding: 24px 0;">조회된 활동 로그 내역이 없습니다.</td></tr>`;
document.getElementById('audit-log-pagination').innerHTML = '';
return; return;
} }
@@ -2305,9 +2367,11 @@
cleanPath = '/' + log.criteria_info.filter(p => p).join('/'); cleanPath = '/' + log.criteria_info.filter(p => p).join('/');
} }
const rowNumber = pagination.total - ((pagination.page - 1) * pagination.limit) - idx;
tr.innerHTML = ` tr.innerHTML = `
<td>${idx + 1}</td> <td>${rowNumber}</td>
<td>${log.clean_date ? log.clean_date.replace('T', ' ').substring(0, 19) : '-'}</td> <td>${formatDate(log.clean_date)}</td>
<td><strong>${log.project_nm || log.project_id || '-'}</strong></td> <td><strong>${log.project_nm || log.project_id || '-'}</strong></td>
<td>${log.user_id || '-'}</td> <td>${log.user_id || '-'}</td>
<td>${log.user_ip || '-'}</td> <td>${log.user_ip || '-'}</td>
@@ -2316,11 +2380,53 @@
`; `;
body.appendChild(tr); body.appendChild(tr);
}); });
renderPaginationControls(pagination);
} catch (err) { } catch (err) {
console.error(err); console.error(err);
} }
} }
function renderPaginationControls(pagination) {
const container = document.getElementById('audit-log-pagination');
container.innerHTML = '';
const { page, totalPages } = pagination;
if (totalPages <= 1) return;
const prevBtn = document.createElement('button');
prevBtn.className = 'pagination-btn';
prevBtn.innerText = '이전';
prevBtn.disabled = page === 1;
prevBtn.onclick = () => renderAuditLogs(page - 1);
container.appendChild(prevBtn);
const maxPageButtons = 5;
let startPage = Math.max(1, page - Math.floor(maxPageButtons / 2));
let endPage = startPage + maxPageButtons - 1;
if (endPage > totalPages) {
endPage = totalPages;
startPage = Math.max(1, endPage - maxPageButtons + 1);
}
for (let i = startPage; i <= endPage; i++) {
const pageBtn = document.createElement('button');
pageBtn.className = `pagination-btn ${i === page ? 'active' : ''}`;
pageBtn.innerText = i;
pageBtn.onclick = () => renderAuditLogs(i);
container.appendChild(pageBtn);
}
const nextBtn = document.createElement('button');
nextBtn.className = 'pagination-btn';
nextBtn.innerText = '다음';
nextBtn.disabled = page === totalPages;
nextBtn.onclick = () => renderAuditLogs(page + 1);
container.appendChild(nextBtn);
}
// --- 8. 글로벌 삭제 정책 설정 탭 (Policies) --- // --- 8. 글로벌 삭제 정책 설정 탭 (Policies) ---
async function renderDeletePolicy() { async function renderDeletePolicy() {
try { try {
@@ -2345,7 +2451,7 @@
const tr = document.createElement('tr'); const tr = document.createElement('tr');
tr.innerHTML = ` tr.innerHTML = `
<td>${idx + 1}</td> <td>${idx + 1}</td>
<td>${l.clean_date ? l.clean_date.replace('T', ' ').substring(0, 19) : '-'}</td> <td>${formatDate(l.clean_date)}</td>
<td><strong>${l.project_id}</strong></td> <td><strong>${l.project_id}</strong></td>
<td style="max-width: 250px; text-overflow: ellipsis; overflow: hidden; white-space: nowrap;">${l.clean_path}</td> <td style="max-width: 250px; text-overflow: ellipsis; overflow: hidden; white-space: nowrap;">${l.clean_path}</td>
<td>${l.criteria_info}</td> <td>${l.criteria_info}</td>

View File

@@ -15,3 +15,48 @@
/* 폰트적용 */ /* 폰트적용 */
/* * { font-family: 'Noto Sans KR', 'Protest Riot', 'Gowun Dodum', sans-serif; } */ /* * { font-family: 'Noto Sans KR', 'Protest Riot', 'Gowun Dodum', sans-serif; } */
/* Noto Sans KR & Noto Serif KR 임포트 */
@import url('https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@300;400;700&family=Noto+Serif+KR:wght@300;400;700&display=swap');
/* 한양굴림, 한컴바탕 등 한글 기본 폰트의 웹 폰트 대체 매핑 */
@font-face {
font-family: '바탕';
src: local('바탕'), local('Batang'), url('https://fonts.gstatic.com/s/notoserifkr/v18/jvra712_5C0LaY5Bp242iPfYE5m3Kg.woff2') format('woff2');
}
@font-face {
font-family: '바탕체';
src: local('바탕체'), local('BatangChe'), url('https://fonts.gstatic.com/s/notoserifkr/v18/jvra712_5C0LaY5Bp242iPfYE5m3Kg.woff2') format('woff2');
}
@font-face {
font-family: '함초롬바탕';
src: local('함초롬바탕'), url('https://fonts.gstatic.com/s/notoserifkr/v18/jvra712_5C0LaY5Bp242iPfYE5m3Kg.woff2') format('woff2');
}
@font-face {
font-family: '돋움';
src: local('돋움'), local('Dotum'), url('https://fonts.gstatic.com/s/notosanskr/v27/PbykF3t50S0B5K_7Jea-vUDz1g.woff2') format('woff2');
}
@font-face {
font-family: '돋움체';
src: local('돋움체'), local('DotumChe'), url('https://fonts.gstatic.com/s/notosanskr/v27/PbykF3t50S0B5K_7Jea-vUDz1g.woff2') format('woff2');
}
@font-face {
font-family: '함초롬돋움';
src: local('함초롬돋움'), url('https://fonts.gstatic.com/s/notosanskr/v27/PbykF3t50S0B5K_7Jea-vUDz1g.woff2') format('woff2');
}
@font-face {
font-family: '굴림';
src: local('굴림'), local('Gulim'), url('https://fonts.gstatic.com/s/notosanskr/v27/PbykF3t50S0B5K_7Jea-vUDz1g.woff2') format('woff2');
}
@font-face {
font-family: '굴림체';
src: local('굴림체'), local('GulimChe'), url('https://fonts.gstatic.com/s/notosanskr/v27/PbykF3t50S0B5K_7Jea-vUDz1g.woff2') format('woff2');
}
@font-face {
font-family: '궁서';
src: local('궁서'), local('Gungsuh'), url('https://fonts.gstatic.com/s/notoserifkr/v18/jvra712_5C0LaY5Bp242iPfYE5m3Kg.woff2') format('woff2');
}
@font-face {
font-family: '궁서체';
src: local('궁서체'), local('GungsuhChe'), url('https://fonts.gstatic.com/s/notoserifkr/v18/jvra712_5C0LaY5Bp242iPfYE5m3Kg.woff2') format('woff2');
}

View File

@@ -425,7 +425,7 @@ body > .control-box .contents-wrap .btn-wrap .btn.selected.map .icon { backgroun
/* 아카이브 우측 영역 (미리보기, 메모(AI요약), 메타데이터) */ /* 아카이브 우측 영역 (미리보기, 메모(AI요약), 메타데이터) */
.archive-main-right { min-width: 41rem; max-width: 41rem; height: 100%; background: #fff; position: relative; } .archive-main-right { min-width: 55rem; max-width: 55rem; height: 100%; background: #fff; position: relative; }
.archive-main-right .viewer-container { display: none; flex-direction: column; justify-content: center; align-items: center; width: 100%; height: 100%; position: relative; } .archive-main-right .viewer-container { display: none; flex-direction: column; justify-content: center; align-items: center; width: 100%; height: 100%; position: relative; }
@@ -1184,12 +1184,12 @@ body > .control-box .contents-wrap .btn-wrap .btn.selected.map .icon { backgroun
.archive-main-center .list-notice .list-notice-box .list-notice-bottom .list-notice-bottom-footer .list-notice-negative h3 { font-size: 0.875rem; font-weight: 500; line-height: 1.25rem; letter-spacing: -0.0175rem; color: #111; } .archive-main-center .list-notice .list-notice-box .list-notice-bottom .list-notice-bottom-footer .list-notice-negative h3 { font-size: 0.875rem; font-weight: 500; line-height: 1.25rem; letter-spacing: -0.0175rem; color: #111; }
.archive-main-center .list-notice .list-notice-box .list-notice-bottom .list-notice-bottom-footer .list-notice-positive h3 { font-size: 0.875rem; font-weight: 500; line-height: 1.25rem; letter-spacing: -0.0175rem; } .archive-main-center .list-notice .list-notice-box .list-notice-bottom .list-notice-bottom-footer .list-notice-positive h3 { font-size: 0.875rem; font-weight: 500; line-height: 1.25rem; letter-spacing: -0.0175rem; }
.archive-main-center .list-notice .list-notice-box .list-notice-bottom .list-notice-bottom-body h4 { font-size: 0.875rem; font-weight: 500; line-height: 1.25rem; letter-spacing: -0.0175rem; } .archive-main-center .list-notice .list-notice-box .list-notice-bottom .list-notice-bottom-body h4 { font-size: 0.875rem; font-weight: 500; line-height: 1.25rem; letter-spacing: -0.0175rem; }
/* .archive-main-center .list-notice .list-notice-box-toggle { background-color: #fff; border: 0.063rem solid #ddd; padding: 0.375rem; border-radius: 0.25rem; display: flex; cursor: pointer; position: absolute; z-index: 11; top: 2.5rem; right: 41.5rem; } */ /* .archive-main-center .list-notice .list-notice-box-toggle { background-color: #fff; border: 0.063rem solid #ddd; padding: 0.375rem; border-radius: 0.25rem; display: flex; cursor: pointer; position: absolute; z-index: 11; top: 2.5rem; right: 55.5rem; } */
.list-notice-box-toggle { background-color: #fff; border: 0.0625rem solid #ddd; border-radius: 0.25rem; padding: 0.25rem; cursor: pointer; position: absolute; z-index: 40; top: 0.625rem; right: 0.625rem; } .list-notice-box-toggle { background-color: #fff; border: 0.0625rem solid #ddd; border-radius: 0.25rem; padding: 0.25rem; cursor: pointer; position: absolute; z-index: 40; top: 0.625rem; right: 0.625rem; }
.archive-main-center .list-notice .list-notice-box-toggle:hover { background-color: #eee;} .archive-main-center .list-notice .list-notice-box-toggle:hover { background-color: #eee;}
.archive-main-center .list-notice .list-notice-box-toggle-menu { display: flex; flex-direction: column; gap: 0.25rem; background-color: #fff; border: 0.063rem solid #ddd; border-radius: 0.25rem; padding: 0.5rem 1rem; min-width: 12rem; max-width:12rem; position: absolute; z-index: 11; top: 4.82rem; right: 41.5rem; box-shadow: 0rem 0.5rem 1.5rem 0rem rgba(0, 0, 0, 0.16); } .archive-main-center .list-notice .list-notice-box-toggle-menu { display: flex; flex-direction: column; gap: 0.25rem; background-color: #fff; border: 0.063rem solid #ddd; border-radius: 0.25rem; padding: 0.5rem 1rem; min-width: 12rem; max-width:12rem; position: absolute; z-index: 11; top: 4.82rem; right: 55.5rem; box-shadow: 0rem 0.5rem 1.5rem 0rem rgba(0, 0, 0, 0.16); }
.archive-main-center .list-notice .list-notice-box-toggle-menu-list { display: flex; align-items: center; gap: 0.5rem; } .archive-main-center .list-notice .list-notice-box-toggle-menu-list { display: flex; align-items: center; gap: 0.5rem; }
.archive-main-center .list-notice .list-notice-box-toggle-menu-list:hover { cursor: pointer; background: #ccc; } .archive-main-center .list-notice .list-notice-box-toggle-menu-list:hover { cursor: pointer; background: #ccc; }
.archive-main-center .list-notice .list-notice-box-toggle-menu-list h3 { font-size: 0.875rem; font-weight: 500; line-height: 1.25rem; letter-spacing: -0.0175rem; color: #111; } .archive-main-center .list-notice .list-notice-box-toggle-menu-list h3 { font-size: 0.875rem; font-weight: 500; line-height: 1.25rem; letter-spacing: -0.0175rem; color: #111; }

View File

@@ -5487,6 +5487,7 @@ export async function renderViewer(resourcePath, dataId, shouldAddClickLog = tru
function viewerExcel(presignedUrl) { function viewerExcel(presignedUrl) {
vars.viewer.innerHTML = '<div style="display:flex;justify-content:center;align-items:center;height:100%;font-size:1.2rem;color:#666;background:#fff;">엑셀 데이터를 불러오는 중...</div>'; vars.viewer.innerHTML = '<div style="display:flex;justify-content:center;align-items:center;height:100%;font-size:1.2rem;color:#666;background:#fff;">엑셀 데이터를 불러오는 중...</div>';
initMainFallbackPdfButton(dataId, resourcePath, objectKey, previewKey);
fetch(presignedUrl) fetch(presignedUrl)
.then(res => { .then(res => {
@@ -5616,18 +5617,33 @@ export async function renderViewer(resourcePath, dataId, shouldAddClickLog = tru
const container = document.createElement('div'); const container = document.createElement('div');
container.style.width = '100%'; container.style.width = '100%';
container.style.height = '100%'; container.style.height = '100%';
container.style.overflow = 'auto'; container.style.overflowX = 'auto';
container.style.overflowY = 'auto';
container.style.padding = '20px'; container.style.padding = '20px';
container.style.boxSizing = 'border-box'; container.style.boxSizing = 'border-box';
container.style.background = '#f5f5f5'; container.style.background = '#f5f5f5';
const styleEl = document.createElement('style');
styleEl.textContent = `
.hwp-inner-container {
background: #ffffff;
margin: 0 auto;
width: max-content;
min-width: 800px;
box-shadow: 0 4px 10px rgba(0,0,0,0.1);
padding: 0 !important;
box-sizing: border-box !important;
min-height: 100%;
}
.hwp-inner-container img {
max-width: 100% !important;
height: auto !important;
}
`;
container.appendChild(styleEl);
const hwpInner = document.createElement('div'); const hwpInner = document.createElement('div');
hwpInner.style.background = '#ffffff'; hwpInner.classList.add('hwp-inner-container');
hwpInner.style.margin = '0 auto';
hwpInner.style.maxWidth = '800px';
hwpInner.style.boxShadow = '0 4px 10px rgba(0,0,0,0.1)';
hwpInner.style.padding = '40px';
hwpInner.style.minHeight = '100%';
container.appendChild(hwpInner); container.appendChild(hwpInner);
vars.viewer.appendChild(container); vars.viewer.appendChild(container);

View File

@@ -733,6 +733,7 @@ export async function renderDocViewer(resourcePath, docId) {
function viewerExcel(presignedUrl) { function viewerExcel(presignedUrl) {
docVars.viewer.innerHTML = '<div style="display:flex;justify-content:center;align-items:center;height:100%;font-size:1.2rem;color:#666;background:#fff;">엑셀 데이터를 불러오는 중...</div>'; docVars.viewer.innerHTML = '<div style="display:flex;justify-content:center;align-items:center;height:100%;font-size:1.2rem;color:#666;background:#fff;">엑셀 데이터를 불러오는 중...</div>';
initDocFallbackPdfButton(docId, resourcePath, objectKey, previewKey);
fetch(presignedUrl) fetch(presignedUrl)
.then(res => { .then(res => {
@@ -862,7 +863,7 @@ export async function renderDocViewer(resourcePath, docId) {
const container = document.createElement('div'); const container = document.createElement('div');
container.style.width = '100%'; container.style.width = '100%';
container.style.height = '100%'; container.style.height = '100%';
container.style.overflowX = 'hidden'; container.style.overflowX = 'auto';
container.style.overflowY = 'auto'; container.style.overflowY = 'auto';
container.style.padding = '20px'; container.style.padding = '20px';
container.style.boxSizing = 'border-box'; container.style.boxSizing = 'border-box';
@@ -873,38 +874,17 @@ export async function renderDocViewer(resourcePath, docId) {
.hwp-inner-container { .hwp-inner-container {
background: #ffffff; background: #ffffff;
margin: 0 auto; margin: 0 auto;
max-width: 800px; width: max-content;
min-width: 800px;
box-shadow: 0 4px 10px rgba(0,0,0,0.1); box-shadow: 0 4px 10px rgba(0,0,0,0.1);
padding: 30px !important; padding: 0 !important;
box-sizing: border-box !important; box-sizing: border-box !important;
min-height: 100%; min-height: 100%;
} }
.hwp-inner-container > div > div {
max-width: 100% !important;
height: auto !important;
box-sizing: border-box !important;
padding-left: 20px !important;
padding-right: 20px !important;
margin-bottom: 20px !important;
}
.hwp-inner-container table {
max-width: 100% !important;
width: 100% !important;
table-layout: fixed !important;
}
.hwp-inner-container img { .hwp-inner-container img {
max-width: 100% !important; max-width: 100% !important;
height: auto !important; height: auto !important;
} }
@media (max-width: 600px) {
.hwp-inner-container {
padding: 10px !important;
}
.hwp-inner-container > div > div {
padding-left: 10px !important;
padding-right: 10px !important;
}
}
`; `;
container.appendChild(styleEl); container.appendChild(styleEl);

View File

@@ -674,6 +674,10 @@ function _openExcel(path, data) {
const viewer = document.getElementById('popup_viewer'); const viewer = document.getElementById('popup_viewer');
viewer.innerHTML = '<div style="display:flex;justify-content:center;align-items:center;height:100%;font-size:1.2rem;color:#666;background:#fff;">엑셀 데이터를 불러오는 중...</div>'; viewer.innerHTML = '<div style="display:flex;justify-content:center;align-items:center;height:100%;font-size:1.2rem;color:#666;background:#fff;">엑셀 데이터를 불러오는 중...</div>';
if (dataId && path_name) {
initFallbackPdfButton(dataId, path_name, resourcePath);
}
fetch(path) fetch(path)
.then(res => { .then(res => {
if (!res.ok) throw new Error(`HTTP ${res.status} ${res.statusText}`); if (!res.ok) throw new Error(`HTTP ${res.status} ${res.statusText}`);
@@ -815,7 +819,7 @@ function _openHwp(path, data) {
const container = document.createElement('div'); const container = document.createElement('div');
container.style.width = '100%'; container.style.width = '100%';
container.style.height = '100%'; container.style.height = '100%';
container.style.overflowX = 'hidden'; container.style.overflowX = 'auto';
container.style.overflowY = 'auto'; container.style.overflowY = 'auto';
container.style.padding = '20px'; container.style.padding = '20px';
container.style.boxSizing = 'border-box'; container.style.boxSizing = 'border-box';
@@ -826,38 +830,17 @@ function _openHwp(path, data) {
.hwp-inner-container { .hwp-inner-container {
background: #ffffff; background: #ffffff;
margin: 0 auto; margin: 0 auto;
max-width: 800px; width: max-content;
min-width: 800px;
box-shadow: 0 4px 10px rgba(0,0,0,0.1); box-shadow: 0 4px 10px rgba(0,0,0,0.1);
padding: 30px !important; padding: 0 !important;
box-sizing: border-box !important; box-sizing: border-box !important;
min-height: 100%; min-height: 100%;
} }
.hwp-inner-container > div > div {
max-width: 100% !important;
height: auto !important;
box-sizing: border-box !important;
padding-left: 20px !important;
padding-right: 20px !important;
margin-bottom: 20px !important;
}
.hwp-inner-container table {
max-width: 100% !important;
width: 100% !important;
table-layout: fixed !important;
}
.hwp-inner-container img { .hwp-inner-container img {
max-width: 100% !important; max-width: 100% !important;
height: auto !important; height: auto !important;
} }
@media (max-width: 600px) {
.hwp-inner-container {
padding: 10px !important;
}
.hwp-inner-container > div > div {
padding-left: 10px !important;
padding-right: 10px !important;
}
}
`; `;
container.appendChild(styleEl); container.appendChild(styleEl);

View File

@@ -3483,6 +3483,9 @@
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/docx-preview@0.1.18/dist/docx-preview.css" /> <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/docx-preview@0.1.18/dist/docx-preview.css" />
<script src="https://cdn.jsdelivr.net/npm/docx-preview@0.1.18/dist/docx-preview.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/docx-preview@0.1.18/dist/docx-preview.min.js"></script>
<!-- wmf.js (WMF 이미지 렌더러) -->
<script src="/libs/wmf.js"></script>
<!-- hwp.js (한글 뷰어) 스크립트 --> <!-- hwp.js (한글 뷰어) 스크립트 -->
<script src="/libs/hwp.js"></script> <script src="/libs/hwp.js"></script>

View File

@@ -133,6 +133,11 @@
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/docx-preview@0.1.18/dist/docx-preview.css" /> <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/docx-preview@0.1.18/dist/docx-preview.css" />
<script src="https://cdn.jsdelivr.net/npm/docx-preview@0.1.18/dist/docx-preview.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/docx-preview@0.1.18/dist/docx-preview.min.js"></script>
<!-- font.css (한글 기본 폰트 매핑) -->
<link rel="stylesheet" href="/main/css/font.css">
<!-- wmf.js (WMF 이미지 렌더링) -->
<script src="/libs/wmf.js"></script>
<!-- hwp.js (한글 뷰어) 스크립트 --> <!-- hwp.js (한글 뷰어) 스크립트 -->
<script src="/libs/hwp.js"></script> <script src="/libs/hwp.js"></script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/github-markdown-css/5.8.1/github-markdown.min.css"> <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/github-markdown-css/5.8.1/github-markdown.min.css">

View File

@@ -1,11 +0,0 @@
대분류,메뉴명,상세 기능 명세,추가 고려사항 (고급 기능)
프로젝트 관리,프로젝트 등록 및 설정,신규 현장/공구의 생성/수정 및 용량 제한(storage_byte) 설정과 잠금 토글(is_active) 관리,비공개/보안(Secret) 폴더에 대한 일반 유저 접근 신청 결재 및 임시 승인 기능
프로젝트 관리,실시간 배너 공지,대시보드 상단 마르퀴 띠배너 공지사항(banner_notice) 등록 및 소켓 기반 실시간 송출 제어,배너 공지 예약 노출 및 송출 이력 관리
사용자 권한 관리,사용자 계정 제어,"사용자 계정 등록, 정보(부서/직급) 수정, 비밀번호 재설정 및 퇴사자 계정 잠금(is_resigned) 처리",외부 SSO(Sentinel) 계정 연동 및 중복 로그인 차단 옵션 관리
사용자 권한 관리,프로젝트 권한 배정,현장별 참여 유저 목록 배정 및 권한 등급(Master/Sub-Master/Worker/Viewer) 클릭 지정,폴더 수준의 세부 접근 제어 리스트(ACL) 추가 연동
서버 및 리소스 모니터링,용량 분석 현황,"현장별 누적 스토리지 사용량, 남은 용량 및 파일/폴더 개수 대시보드 시각화",스토리지 임계값(예: 90%) 도달 시 관리자 자동 경고 알림
서버 및 리소스 모니터링,압축 다운로드 관리,비동기 폴더 압축 다운로드(BullMQ) 작업의 진행 현황 모니터링 및 대기열 제어,임시 보존 기간이 지난 압축 임시파일 자동/일괄 영구 삭제를 통한 디스크 확보
시스템 감사 및 통계,감사 로그 조회,"파일 삭제, 이동, 다운로드 등 민감한 조작 행위(tb_log)의 날짜/유저/활동별 필터 검색",감사 로그 이력 보고서 인쇄 및 엑셀 백업 다운로드
시스템 감사 및 통계,행동 분석 및 통계,사용자 UI 클릭 로그(tb_click_log) 기반 최다 접근 메뉴 및 최다 조회 도면/문서 사용 현황 분석,AI 요약 서비스(Gemini API) 호출 제한량(Quota) 관리 및 비용 통제
실시간 접속자,실시간 접속 현황,현재 소켓 연결된 동시 접속 유저 목록 및 접속 IP와 현재 탐색 중인 아카이브 경로 실시간 표출,특정 유저 강제 소켓 연결 끊기(Kick) 기능
시스템 환경 및 정책 관리,보관 및 삭제 정책 설정,기존 pageRenderer.js에 하드코딩된 '최소 유지 파일 개수(3개)' 및 '보존 기간(15일)' 임계치를 DB화하여 관리자 UI에서 동적 설정 기능 제공,tb_project 테이블에 limit_file_count 및 limit_days 컬럼을 연동하여 프론트엔드가 변경된 기준값으로 실시간 바인딩되도록 API 개선
1 대분류 메뉴명 상세 기능 명세 추가 고려사항 (고급 기능)
2 프로젝트 관리 프로젝트 등록 및 설정 신규 현장/공구의 생성/수정 및 용량 제한(storage_byte) 설정과 잠금 토글(is_active) 관리 비공개/보안(Secret) 폴더에 대한 일반 유저 접근 신청 결재 및 임시 승인 기능
3 프로젝트 관리 실시간 배너 공지 대시보드 상단 마르퀴 띠배너 공지사항(banner_notice) 등록 및 소켓 기반 실시간 송출 제어 배너 공지 예약 노출 및 송출 이력 관리
4 사용자 권한 관리 사용자 계정 제어 사용자 계정 등록, 정보(부서/직급) 수정, 비밀번호 재설정 및 퇴사자 계정 잠금(is_resigned) 처리 외부 SSO(Sentinel) 계정 연동 및 중복 로그인 차단 옵션 관리
5 사용자 권한 관리 프로젝트 권한 배정 현장별 참여 유저 목록 배정 및 권한 등급(Master/Sub-Master/Worker/Viewer) 클릭 지정 폴더 수준의 세부 접근 제어 리스트(ACL) 추가 연동
6 서버 및 리소스 모니터링 용량 분석 현황 현장별 누적 스토리지 사용량, 남은 용량 및 파일/폴더 개수 대시보드 시각화 스토리지 임계값(예: 90%) 도달 시 관리자 자동 경고 알림
7 서버 및 리소스 모니터링 압축 다운로드 관리 비동기 폴더 압축 다운로드(BullMQ) 작업의 진행 현황 모니터링 및 대기열 제어 임시 보존 기간이 지난 압축 임시파일 자동/일괄 영구 삭제를 통한 디스크 확보
8 시스템 감사 및 통계 감사 로그 조회 파일 삭제, 이동, 다운로드 등 민감한 조작 행위(tb_log)의 날짜/유저/활동별 필터 검색 감사 로그 이력 보고서 인쇄 및 엑셀 백업 다운로드
9 시스템 감사 및 통계 행동 분석 및 통계 사용자 UI 클릭 로그(tb_click_log) 기반 최다 접근 메뉴 및 최다 조회 도면/문서 사용 현황 분석 AI 요약 서비스(Gemini API) 호출 제한량(Quota) 관리 및 비용 통제
10 실시간 접속자 실시간 접속 현황 현재 소켓 연결된 동시 접속 유저 목록 및 접속 IP와 현재 탐색 중인 아카이브 경로 실시간 표출 특정 유저 강제 소켓 연결 끊기(Kick) 기능
11 시스템 환경 및 정책 관리 보관 및 삭제 정책 설정 기존 pageRenderer.js에 하드코딩된 '최소 유지 파일 개수(3개)' 및 '보존 기간(15일)' 임계치를 DB화하여 관리자 UI에서 동적 설정 기능 제공 tb_project 테이블에 limit_file_count 및 limit_days 컬럼을 연동하여 프론트엔드가 변경된 기준값으로 실시간 바인딩되도록 API 개선

View File

@@ -0,0 +1,48 @@
# 문서 뷰어(미리보기) 확장자별 지원 사양 및 기술 사유 분석
본 문서는 본 프로젝트의 문서 미리보기 시스템에서 **지원 가능한 파일 확장자 목록과 구현 방식**, 그리고 **일부 포맷이 미리보기가 불가능하거나 제한적인 기술적 사유**를 체계적으로 정리한 기술 문서입니다.
---
## 1. 지원 가능한 확장자 및 뷰잉 방식 (Supported Formats)
시스템에서 감지 및 렌더링이 가능한 확장자는 크게 **(1) 웹 브라우저 직접 렌더링 방식**, **(2) 서버 측 PDF 변환 폴백 방식**, **(3) 전문 뷰어 연동 방식**으로 분류됩니다.
### 1.1 문서 및 도면 파일 (Document & CAD)
| 확장자 | 브라우저 직접 뷰잉 방식 | 서버 PDF 변환 뷰잉 | 미리보기 지원 상태 및 특이사항 |
| :--- | :--- | :--- | :--- |
| **pdf** | 자체 PDF.js 뷰어로 즉시 출력 | (변환 불필요) | **[상시 지원]** 표준 문서 포맷으로 100% 원본 렌더링 지원 |
| **hwp, hwpx** | `hwp.js` 라이브러리로 직접 뷰잉 | **LibreOffice PDF 변환본 로드** | **[상시 지원]** 직접 뷰잉 도중 레이아웃 겹침 발생 시 상단 "PDF로 보기" 버튼을 눌러 원본 고정밀 보기 지원 |
| **docx** | `docx-preview` 라이브러리로 직접 뷰잉 | **OfficeToPDF PDF 변환본 로드** | **[상시 지원]** 직접 뷰잉 도중 서식 누락 발생 시 상단 "PDF로 보기" 버튼을 눌러 원본 고정밀 보기 지원 |
| **xlsx, xls, xlsm** | `Luckysheet/LuckyExcel`로 시트 뷰잉 | **OfficeToPDF PDF 변환본 로드** | **[상시 지원]** 직접 시트 뷰잉 시 도형(Shapes)이 안 보일 때 상단 "PDF로 보기" 버튼을 눌러 원본 고정밀 보기 지원 |
| **doc** | 직접 뷰잉 불가 (이진 포맷 한계) | **OfficeToPDF PDF 변환본 로드** | **[상시 지원]** 백엔드에서 PDF로 즉시 자동 변환하여 고해상도 미리보기 화면을 출력함 |
| **ppt, pptx** | 직접 뷰잉 불가 (웹 파서 미비) | **OfficeToPDF PDF 변환본 로드** | **[상시 지원]** 백엔드에서 PDF로 즉시 자동 변환하여 고해상도 미리보기 화면을 출력함 |
| **dwg, dxf** | 직접 뷰잉 불가 (CAD 도면 엔진 필요) | **DwgToPdfSwigConverter 로드** | **[상시 지원]** 백엔드에서 PDF로 즉시 자동 변환하여 고해상도 미리보기 화면을 출력함 |
| **grm** | 직접 뷰잉 불가 | **서버 PDF 변환본 로드** | **[상시 지원]** 백엔드에서 PDF로 즉시 자동 변환하여 고해상도 미리보기 화면을 출력함 |
### 1.2 미디어 및 기타 포맷 (Media & Others)
| 분류 | 확장자 | 브라우저 렌더링 방식 | 상세 설명 |
| :--- | :--- | :--- | :--- |
| **이미지** | `png, jpg, jpeg, webp, gif` | `<img>` 태그 렌더링 | 브라우저 표준 그래픽 렌더링 사용. 이미지 메타데이터가 파노라마(`panorama`)인 경우 별도의 3D 파노라마 뷰어(`pannellum`) 연동 |
| **동영상** | `mp4, mov, webm` | `<video>` 태그 렌더링 | HTML5 표준 비디오 코덱 플레이어 사용 |
| **텍스트** | `txt, log, md` | `<pre>` 태그 렌더링 | 인코딩 감지 후 플레인 텍스트 렌더링 (마크다운은 스타일링 적용) |
| **3D 모델** | `glb, gltf, obj, stl, fbx, 3dm` | `Three.js` 3D 뷰어 연동 | WebGL 기반의 3D 객체 직접 렌더링 및 회전/스케일 제어 지원 |
| **공간 정보** | `gsim` | GSIM 전용 뷰어 웹앱 연동 | 별도 마운트된 GSIM 엔진 프레임 내부 렌더링 |
| **BIM 모델** | `ifc` | IFC 뷰어 전용 iframe 연동 | `web-ifc-three` 기반의 BIM 모델링 구조 브라우저 직접 렌더링 |
| **기타** | `zip` | 폴더/파일 트리 렌더링 | 압축을 풀지 않고 내부 디렉토리 아키텍처를 JSON으로 트리 뷰잉 |
| **링크** | `url` | iframe 또는 새 창 이동 | 등록된 외부 URL 리다이렉트 처리 |
| **웹문서** | `html` | iframe 렌더링 | HTML 샌드박스 프레임 내부 렌더링 |
---
## 2. 미리보기 미지원 확장자 및 기술 사유 (Unsupported Formats)
아래 확장자들은 **미리보기가 불가능하며 오직 파일 다운로드만 지원**됩니다. 그 구체적인 기술 사유는 다음과 같습니다.
| 파일 분류 | 대상 확장자 (예시) | 미리보기 미지원 기술 사유 |
| :--- | :--- | :--- |
| **실행 파일** | `exe, msi, bat, sh, cmd, com` | * **보안 위험**: 웹 브라우저 내에서 OS 실행 파일을 구동하는 것은 크로스 사이트 스크립팅(XSS) 및 악성코드 실행 방지를 위해 보안상 원천 차단됩니다. <br> * **플랫폼 독립성**: 브라우저 샌드박스 환경에서는 로컬 머신의 커널에 직접 액세스하여 실행 프로그램을 실행할 수 없습니다. |
| **압축 파일** | `rar, 7z, tar, gz, alz, egg` | * **압축 알고리즘 독점성**: `.zip`을 제외한 `.rar`, `.7z` 등은 압축 및 해제 알고리즘이 브라우저 JavaScript 단에서 처리하기에 라이브러리가 매우 무겁거나, 유료 라이선스 제약(독점 포맷)이 있습니다. <br> * **성능 저하**: 클라이언트 브라우저 메모리 상에서 기가바이트 단위의 대용량 압축 파일을 직접 해제하여 트리 구조를 빌드하는 것은 브라우저 탭 다운을 유발합니다. |
| **데이터베이스** | `db, sqlite, mdb, sql, accdb` | * **연결 세션성**: DB 파일은 전용 DBMS(엔진)가 백그라운드에서 구동되어 인덱스 쿼리를 수행해야 구조 열람이 가능합니다. 브라우저에서 바이너리 파일 그 자체를 텍스트나 그림처럼 단순 뷰잉하는 것은 논리적으로 불가능합니다. |
| **오디오 파일** | `mp3, wav, ogg, flac, m4a` | * **기획적 요구 부재**: 시스템이 문서 관리 및 3D 모델 협업 중심이므로 오디오 플레이어 탑재의 우선순위가 배제되어 있으며, 필요 시 브라우저 내장 플레이어로 다운로드 대체가 가능합니다. |
| **소스 코드** | `java, py, cpp, cs, go, ts, js, css` | * **텍스트로 뷰잉 가능 여부**: `.txt`와 동일하게 텍스트 에디터 방식으로 보여줄 수 있으나, 현재 시스템은 개발자용 IDE 환경이 아닌 일반 비즈니스 문서용이므로 코딩 확장자들은 다운로드 전용으로 제한됩니다. |
| **대용량 원시 그래픽** | `psd, ai, eps, indd, tiff` | * **독점 그래픽 포맷**: Adobe사 등의 전용 디자인 툴에서 쓰이는 포맷으로, 웹 표준 이미지 태그(`<img>`)가 인식하지 못하며, 클라이언트 단의 JS 파싱 라이브러리가 존재하지 않거나 극도로 불안정합니다. |
| **폰트 파일** | `ttf, otf, woff, eot` | * **시스템 자원**: 폰트 데이터는 글꼴 메타데이터의 모음이므로 독립적인 시각적 형태를 브라우저 캔버스 상에 프리뷰할 논리적 근거가 부재합니다. |

View File

@@ -0,0 +1,67 @@
# 엑셀 파일 미리보기 시 도형(Shapes) 렌더링 한계 분석 및 대응 방안
본 문서는 웹 브라우저 환경에서 엑셀(`.xlsx`) 파일을 직접 파싱하여 미리보기 화면을 구성할 때, **도형(Shapes, 화살표, 선, 프로세스 차트 등)이 렌더링되지 않는 기술적 원인**과 **한글(HWP) 파일 파싱 방식과의 구조적 차이점**, 그리고 이를 보완하기 위한 **최선의 대응 방안**을 정리한 기술 분석서입니다.
---
## 1. 개요 및 현상
* **정상 작동 영역**: 셀 데이터(텍스트, 숫자, 날짜), 셀 스타일(배경색, 폰트 크기, 테두리), 시트 수식, 셀 병합 상태, 그리고 일부 부유형 이미지(그림 파일)는 브라우저 미리보기 화면에서 정상적으로 표현됩니다.
* **비정상 작동 영역**: 사각형, 원형, 화살표, 말풍선, 블록 화살표, 다이어그램, 연결선 등 **엑셀 본문 그리드 위에 독립적으로 배치된 드로잉 도형 객체(Shapes/Drawings)**들은 화면에 아예 표시되지 않거나 누락되는 현상이 발생합니다.
---
## 2. 기술적 원인 분석 (오픈소스 라이브러리의 한계)
본 시스템은 웹 브라우저 단에서 서버 리소스를 쓰지 않고 빠르게 엑셀을 렌더링하기 위해 다음 두 가지 라이브러리를 결합하여 사용하고 있습니다.
1. **LuckyExcel**: 엑셀(`.xlsx` binary/zip) 파일을 자바스크립트 객체(JSON) 데이터 모델로 변환해주는 파서.
2. **Luckysheet**: 변환된 JSON 모델을 바탕으로 웹 브라우저 화면에 엑셀 시트 그리드를 그려주고 셀 동작을 처리해주는 렌더러.
이 두 오픈소스 라이브러리는 태생적으로 **"셀(Cell) 중심의 그리드 표현"**에 특화되어 개발되었기 때문에 다음과 같은 한계를 가집니다.
### ① LuckyExcel (파서) 측면의 한계 (XML 스펙 무시)
* 엑셀 파일(`.xlsx`)은 OpenXML 포맷을 따르며, 시트 내에 배치된 도형 정보는 `xl/drawings/drawing1.xml`과 같은 파일 내에 **DrawingML** 규격으로 기록됩니다.
* LuckyExcel은 이 XML 파일을 파싱할 때 이미지 객체를 정의하는 `<xdr:pic>` 태그만 선별적으로 추출하여 Luckysheet의 이미지 모델로 넘겨줍니다.
* 반면, 일반 도형을 정의하는 **`<xdr:sp>`(Shape)**나 연결선을 정의하는 **`<xdr:cxnSp>`(Connection Shape)** 등 기타 드로잉 엘리먼트들은 **코드 레벨에서 파싱을 전혀 지원하지 않고 무시**합니다.
### ② Luckysheet (렌더러) 측면의 한계 (드로잉 모델 부재)
* Luckysheet의 내부 데이터 스키마에는 셀 서식, 수식, 그리고 부유형 이미지를 관리하는 속성(`images`)은 설계되어 있으나, 다각형이나 선, 벡터 그래픽을 추상화하여 저장하고 실시간으로 그려주는 **도형 전용 데이터 규격 및 컴포넌트(Drawing Engine)가 존재하지 않습니다.**
* 이미지는 웹 브라우저가 기본적으로 지원하는 `<img>` 태그를 절대 좌표 기반으로 얹어주면 비교적 단순하게 렌더링할 수 있지만, 벡터 도형은 브라우저 상에서 가변적으로 그리기가 매우 까다로워 처음부터 설계 범위에서 배제되었습니다.
* **추가 이슈**: 현재 Luckysheet 프로젝트는 공식적으로 개발이 중단(Unmaintained)되었으며, 후속 아키텍처 프로젝트인 Univer로 이전이 권장되고 있어 향후에도 도형 렌더링 기능이 보완될 가능성이 없습니다.
---
## 3. 한글(HWP) 파일 파싱 방식과의 차이점
"한글 파일은 도형이 나오는데 엑셀은 왜 구현이 불가능한가?"에 대한 구조적 비교는 다음과 같습니다.
| 비교 항목 | 한글(HWP) 미리보기 파싱 | 엑셀(Excel) 미리보기 파싱 |
| :--- | :--- | :--- |
| **사용 라이브러리** | `hwp.js` (자체 커스텀 버전) | `LuckyExcel` + `Luckysheet` (외부 CDN 주입) |
| **도형 데이터 모델** | HWP 스펙에 정의된 Line, Rectangle, Ellipse 객체 모델을 내부에 명확히 파싱하여 보유함 | 데이터 모델(JSON) 상에 도형에 대한 정보(좌표, 타입, 스타일 등)를 담을 필드 자체가 없음 |
| **렌더링 방식** | 추출된 도형 노드를 브라우저 화면상의 **HTML5 SVG(Scalable Vector Graphics)**로 직접 변환하여 문서 플로우 내에 동적으로 삽입 및 드로잉 처리함 | 시트 좌표계에 독립적인 드로잉 레이어나 SVG 변환 규칙이 존재하지 않으며, 오직 Canvas 표 위에 텍스트/셀 테두리만 출력함 |
| **개선 가능 범위** | 라이브러리 소스 코드가 프로젝트 내(`libs/hwp.js`)에 패키징되어 있어 좌표 보정이나 텍스트 누락 방어 등의 수정이 유연하게 가능함 | 외부 라이브러리이며, 도형 표현을 위해선 파서와 렌더링 캔버스 엔진을 바닥부터 다시 코딩해야 하므로 프로젝트 규모의 프레임워크 재설계가 필요함 |
---
## 4. 직접 구현 시의 기술적 난제 (CAD/웹 오피스 수준의 공수)
서버 도움 없이 클라이언트 브라우저 단에서 엑셀 도형을 직접 그리려면 다음과 같은 고난도의 기술적 해결책을 직접 만들어야 합니다.
1. **DrawingML Parser 개발**:
`JSZip`으로 엑셀 파일 내의 `xl/drawings/drawingX.xml`을 압축 해제하고 DOMParser로 열어 각 도형의 크기, 회전각, 선 스타일, 그라데이션, 정렬, 텍스트 상자 등을 파싱하는 코드를 개발해야 합니다.
2. **동적 픽셀 좌표(Dynamic Cell-to-Pixel) 매핑 엔진**:
* 엑셀에서 도형 위치는 절대 픽셀이 아니라, 특정 셀을 기준으로 한 위치인 **"Two Cell Anchor"** 구조로 관리됩니다.
* *예: "C열 4번째 행의 왼쪽으로부터 10px, 위로부터 5px 떨어진 곳에서 시작하여 E열 8번째 행까지..."*
* 따라서 엑셀 시트 내의 열 너비(Column Width)와 행 높이(Row Height)의 동적 변화를 실시간으로 가산하여 정확한 웹 픽셀(X, Y, Width, Height)로 치환해야 합니다.
3. **스크롤, 확대/축소 및 열 너비 조절 동기화**:
* 사용자가 그리드를 마우스로 조작(스크롤, 컬럼 너비 리사이즈, 시트 확대/축소)할 때마다 SVG 도형 객체의 크기와 좌표도 실시간 재계산 및 Repositioning 처리를 해야 하며, 싱크가 1ms라도 어긋나면 도형이 표 위에 둥둥 떠다니는 왜곡 현상이 발생합니다.
---
## 5. 프로젝트 내 조치 내용 및 우회 해결 방법 (Best Practice)
웹 브라우저의 프론트엔드 파싱만으로는 엑셀 본래의 완벽한 폰트, 다이어그램, 차트 및 수많은 도형 레이아웃을 100% 보존하여 출력할 수 없습니다.
이 때문에 업계 표준(Google Docs, Slack, Confluence 등)에 맞춰 **서버 측 고성능 오피스 변환 엔진**을 이용한 **PDF 뷰어 폴백(Fallback) 방식을 조합하여 최선의 UX를 제공**하도록 처리했습니다.
### 적용된 우회 해결책
* **"PDF로 보기" 버튼 상시 노출 처리 (2026-06-19 패치)**:
* 기존에는 엑셀 파싱 에러(포맷 불일치 등)가 났을 경우에만 "PDF로 보기" 버튼이 활성화되어, 정상적으로 시트가 열린 상태에서 도형이 깨져 보이는 문서는 원본을 열람할 수 없었습니다.
* 엑셀 뷰어가 실행되는 즉시 화면 상단 우측에 **"PDF로 보기"** 버튼이 항상 노출되도록 3개 뷰어 모듈(`pageRenderer.js`, `docPageRenderer.js`, `popup.js`)을 개선하였습니다.
* 사용자는 도형 및 완벽한 레이아웃 보존이 필요한 경우 **"PDF로 보기"** 버튼을 눌러 서버가 변환한 정밀한 PDF 뷰어 화면으로 즉시 스위칭하여 원하는 내용을 고화질로 확인할 수 있습니다.

View File

@@ -1,4 +1,4 @@
# 한글 파일(HWP) 클라이언트 사이드 미리보기 구현 및 기술 명세서 # 한글 파일(HWP) 클라이언트 사이드 미리보기 구현 및 기술 명세서 (고도화 버전)
본 문서는 서버 자원 소모 없이 웹 브라우저(클라이언트) 단에서 `.hwp` 파일을 직접 파싱하고 안정적으로 이미지를 비롯한 도형 요소까지 화면에 렌더링하도록 커스텀 반영한 작업 내용, 구현 로직, 아키텍처 및 코드 명세를 다룹니다. 본 문서는 서버 자원 소모 없이 웹 브라우저(클라이언트) 단에서 `.hwp` 파일을 직접 파싱하고 안정적으로 이미지를 비롯한 도형 요소까지 화면에 렌더링하도록 커스텀 반영한 작업 내용, 구현 로직, 아키텍처 및 코드 명세를 다룹니다.
@@ -6,7 +6,7 @@
## 1. 미리보기 아키텍처 및 데이터 흐름 ## 1. 미리보기 아키텍처 및 데이터 흐름
클라이언트 브라우저가 HWP 바이너리를 가져와 렌더링하기까지의 흐름입니다. 클라이언트 브라우저가 HWP 바이너리를 가져와 렌더링하기까지의 전체적인 흐름입니다.
```mermaid ```mermaid
graph TD graph TD
@@ -14,49 +14,90 @@ graph TD
B --> C["hwp.js Viewer 초기화"] B --> C["hwp.js Viewer 초기화"]
C --> D["OLE Compound 파일 구조 해석"] C --> D["OLE Compound 파일 구조 해석"]
D --> E["BinData 가상 폴더 내 이미지 파싱"] D --> E["BinData 가상 폴더 내 이미지 파싱"]
E --> F{"Magic Number 분석 (Raw 이미지 여부)"} E --> F{"Magic Number 분석\nRaw 이미지 여부 판별"}
F -->|"PNG/JPEG/GIF/WMF 헤더 일치"| G["Decompress 건너뛰기 (Raw 복사)"] F -->|"PNG/JPEG/GIF/WMF 헤더 일치"| G["Decompress 건너뛰기\nRaw 복사"]
F -->|"헤더 불일치 (압축 상태)"| H["pako.inflate (윈도우 비트 -15) 실행"] F -->|"헤더 불일치 - 압축 상태"| H["pako.inflate 실행\nwindowBits: -15"]
H -->|"실패 시"| I["표준 inflate / raw / 원본 복사 폴백"] H -->|"실패 시"| I["표준 inflate / raw / 원본 복사 폴백"]
G --> J["바이너리 데이터 Uint8Array 래핑"] G --> J["바이너리 데이터 Uint8Array 래핑"]
I --> J I --> J
J --> K["Blob 객체 및 URL.createObjectURL 주소 생성"] J --> K["Blob 객체 및 URL.createObjectURL 생성"]
K --> L{"컨트롤 타입 (control.type) 판별"} K --> L{"control.type 판별"}
L -->|"그림 (Picture)"| M["shapeGroup div에 backgroundImage 매핑"] L -->|"그림 Picture"| M{"확장자 판별"}
L -->|"도형 (Rectangle/Ellipse/Line/Polygon/Arc/Curve)"| N["도형 테두리 선, 둥글기, 연한 배경색 스타일 및 텍스트 렌더링"] M -->|"일반 이미지 PNG/JPG"| M1["shapeGroup div에\nbackgroundImage 매핑"]
M --> O["최종 웹 뷰어 화면 렌더링"] M -->|"벡터 이미지 WMF"| M2["wmf.js Canvas 렌더링 후\nPNG 변환 바인딩"]
L -->|"도형 Rectangle/Ellipse/Line 등"| N["shapeGroup 내 SVG 배경 레이어 삽입\nrect, ellipse, line 요소 매핑"]
M1 --> O["최종 웹 뷰어 화면 렌더링"]
M2 --> O
N --> O N --> O
``` ```
--- ---
## 2. 해결한 핵심 이슈 및 작업 내용 ## 2. 해결한 핵심 이슈 및 작업 내용
### 2.1 OLE 이미지 압축 해제(Decompression) 안정화 및 우회 로직 ### 2.1 OLE 이미지 압축 해제(Decompression) 안정화 및 우회 로직
* **현상:** 구형 HWP 파서 라이브러리(`hwp.js`)는 `pako.inflate`를 수행할 때 윈도우 비트 옵션(`{ windowBits: -15 }`)을 무조건적으로 주입해 압축을 해제했습니다. 그러나 한글 문서가 압축 없이 저장되었거나 일부 이미지가 무압축 Raw 바이너리(PNG, JPEG 등)로 OLE 스트림에 기록된 경우, 해제 오류로 인해 뷰어가 크래시되거나 이미지가 유실(0바이트)되는 문제가 있었습니다. * **현상**: 기존 HWP 파서 라이브러리(`hwp.js`)는 `pako.inflate`를 수행할 때 윈도우 비트 옵션(`{ windowBits: -15 }`)을 무조건적으로 주입해 압축을 해제했습니다. 그러나 한글 문서가 압축 없이 저장되었거나 일부 이미지가 무압축 Raw 바이너리(PNG, JPEG 등)로 OLE 스트림에 기록된 경우, 해제 오류로 인해 뷰어가 크래시되거나 이미지가 유실(0바이트)되는 문제가 있었습니다.
* **조치:** * **조치**:
1. 압축 해제 전 바이너리의 **첫 4바이트(Magic Number) 시그니처**를 대조하여 웹 표준 파일 유형(PNG, JPEG, GIF, WMF) 검사를 선행합니다. 1. 압축 해제 전 바이너리의 **첫 4바이트(Magic Number) 시그니처**를 대조하여 웹 표준 파일 유형(PNG, JPEG, GIF, WMF) 검사를 선행합니다.
2. 이미 시그니처를 충족하는 Raw 이미지의 경우 압축 해제를 우회하도록 최적화하여 렌더링 성능 안정성을 향상했습니다. 2. 이미 시그니처를 충족하는 Raw 이미지의 경우 압축 해제를 우회하도록 최적화하여 렌더링 성능 안정성을 향상했습니다.
3. 압축이 필요한 경우 3단계 예외 처리 폴백(`windowBits: -15` -> `표준 inflate` -> `inflateRaw` -> `원본 바이너리`)을 구축하여 어떠한 조건에서도 파싱이 멈추지 않도록 조치했습니다. 3. 압축이 필요한 경우 3단계 예외 처리 폴백(`windowBits: -15` -> `표준 inflate` -> `inflateRaw` -> `원본 바이너리`)을 구축하여 어떠한 조건에서도 파싱이 멈추지 않도록 조치했습니다.
### 2.2 MIME 타입 오류 수정 및 바이너리 안전성 보장 ### 2.2 MIME 타입 오류 수정 및 바이너리 안전성 보장
* **현상:** 기존 뷰어 소스 코드에 `type: "images/".concat(extension)`이라는 치명적인 오타가 있었습니다. 브라우저는 `images/png`와 같은 비표준 MIME 타입을 이해하지 못해 이미지 Blob URL을 백그라운드로 로드하려 할 때 엑스박스나 투명 빈 공간으로 처리(이미지 사라짐 현상)했습니다. 또한, 브라우저 환경에 따라 OLE 파서가 원본을 일반 Array 형태로 리턴할 때 문자열로 깨지는 위험이 존재했습니다. * **현상**: 기존 뷰어 소스 코드에 `type: "images/".concat(extension)`이라는 치명적인 오타가 있었습니다. 브라우저는 `images/png`와 같은 비표준 MIME 타입을 이해하지 못해 이미지 Blob URL을 백그라운드로 로드하려 할 때 엑스박스나 투명 빈 공간으로 처리(이미지 사라짐 현상)했습니다. 또한, 브라우저 환경에 따라 OLE 파서가 원본을 일반 Array 형태로 리턴할 때 문자열로 깨지는 위험이 존재했습니다.
* **조치:** * **조치**:
1. MIME 타입을 표준 규격에 맞게 `"image/".concat(extension)`으로 전면 변경하고, `jpg` 확장자는 브라우저 표준 명칭인 `image/jpeg`로 정확히 매핑했습니다. 1. MIME 타입을 표준 규격에 맞게 `"image/".concat(extension)`으로 전면 변경하고, `jpg` 확장자는 브라우저 표준 명칭인 `image/jpeg`로 정확히 매핑했습니다.
2. Blob 생성 시 바이트의 깨짐을 방지하기 위해 생성자 주입 전 데이터 타입을 검증하고 `Uint8Array` 인스턴스로 안전하게 래핑하도록 보장했습니다. 2. Blob 생성 시 바이트의 깨짐을 방지하기 위해 생성자 주입 전 데이터 타입을 검증하고 `Uint8Array` 인스턴스로 안전하게 래핑하도록 보장했습니다.
### 2.3 한글 문서 자체 생성 도형(Shape Object) 렌더링 기능 추가 ### 2.3 한글 문서 자체 생성 도형(Shape Object) 렌더링의 SVG 배경 레이어 적용 및 위치 정합성 교정
* **현상:** 본문 내에 삽입된 이미지는 로드가 완료되었으나, 문서 편집기 내부에서 자체 제작한 **직사각형(Rectangle), 타원(Ellipse), 선(Line), 다각형(Polygon) 등의 벡터 도형**은 파서가 태그 분류를 누락하여 완전히 투명하게 렌더링되었습니다. 이로 인해 테두리와 사각형 배경 없이 글자만 겹쳐서 표시되는 문제가 있었습니다. * **현상**: 본문 내에 삽입된 이미지는 로드가 완료되었으나, 문서 편집기 내부에서 자체 제작한 **직사각형(Rectangle), 타원(Ellipse), 선(Line), 다각형(Polygon) 등의 벡터 도형**은 파서가 태그 분류를 누락하여 완전히 투명하게 렌더링되거나, 상대 배치 마진 누적으로 인해 본문 뒤로 엉뚱하게 밀려나서 페이지 바깥의 회색 프레임에 잘못 렌더링되는 문제가 있었습니다.
* **조치:** * **조치**:
1. HWP 파서의 핵심 순회 구조(`visit` 스위치-케이스 문) 내에 누락된 도형 컴포넌트 태그 ID들을 등록하여 파싱 단계에서 도형 종류(`control.type`)를 올바르게 정의하도록 했습니다. 1. HWP 파서의 핵심 순회 구조(`visit` 스위치-케이스 문) 내에 누락된 도형 컴포넌트 태그 ID들을 등록하여 파싱 단계에서 도형 종류(`control.type`)를 올바르게 정의하도록 했습니다.
2. 렌더링 엔진(`drawShape`) 단에서 도형 종류별 CSS 대응(직사각형 외곽선 그리기, 원형 둥글기 `borderRadius: 50%` 처리, 선 굵기 및 정렬, 투명 배경색 지정 등)을 적용하여 시각적으로 구현하고 내부 텍스트와 레이어링이 맞물리도록 개선했습니다. 2. 렌더링 엔진(`drawShape`) 단에서 단순 HTML/CSS 스타일로 형태만 구현하던 단독 태그 방식 대신, 글씨 배치가 되는 부모 컨테이너 `div` 내부에 **절대 배치된 `<svg>` 배경 레이어를 생성하여 `<rect>`, `<ellipse>`, `<line>` 요소를 주입**해 벡터 윤곽선을 정밀하게 구현했습니다.
3. 도형의 세로 기준 설정(`vertRelTo`) 속성을 해석하여 분기 처리했습니다:
- `vertRelTo === 2` (문단 기준): 문단 컨테이너 `div``position: relative` 스타일을 부여하고 도형을 `position: absolute; top: [verticalOffset]; left: [horizontalOffset];`로 정밀 정렬하였습니다.
- `vertRelTo === 0 또는 1` (종이/쪽 기준): 도형을 부모 쪽 컨테이너(`container.parentNode`)의 절대 레이어에 직접 덧그려 배치했습니다.
- `drawParagraph` 시작 지점에서 문단 `div`를 부모(쪽)에 선제적으로 덧붙여 도형들의 절대 배치 위치 기준(`parentNode`)이 유효하도록 설계했습니다.
### 2.4 단락 및 텍스트 줄 간격(Line Spacing) 조절을 통한 겹침 결함 조치 ### 2.4 단락 및 텍스트 줄 간격(Line Spacing) 조절을 통한 겹침 결함 조치
* **현상:** 뷰어 화면의 텍스트 줄 간격이 브라우저 기본값(normal)을 사용하여 좁게 나타날 뿐만 아니라, 프로젝트 전반의 글로벌 CSS 규격에 의해 자식 요소인 `div` 태그의 `line-height` 속성이 덮어씌워져 줄 영역이 위아래로 심하게 겹쳐 보이는 시각적 오류가 발생했습니다. * **현상**: 뷰어 화면의 텍스트 줄 간격이 브라우저 기본값(normal)을 사용하여 좁게 나타날 뿐만 아니라, 프로젝트 전반의 글로벌 CSS 규격에 의해 자식 요소인 `div` 태그의 `line-height` 속성이 덮어씌워져 줄 영역이 위아래로 심하게 겹쳐 보이는 시각적 오류가 발생했습니다.
* **조치:** * **조치**:
1. `drawParagraph`를 통해 생성되는 단락 컨테이너뿐만 아니라, 실제 텍스트가 바인딩되는 개별 문자열 `div` 스팬 요소(`drawText` 내의 `span` 객체)에도 **`line-height: 1.65` 인라인 스타일을 강제 적용**했습니다. 1. `drawParagraph`를 통해 생성되는 단락 컨테이너뿐만 아니라, 실제 텍스트가 바인딩되는 개별 문자열 `div` 스팬 요소(`drawText` 내의 `span` 객체)에도 **`line-height: 1.65` 인라인 스타일을 강제 적용**했습니다.
2. 인라인 스타일을 직접 지정함으로써 글로벌 스타일시트(CSS) 셀렉터에 의한 오버라이드를 완전 차단하고, 텍스트 줄 간 영역이 서로 침범 및 겹침 현상 없이 한글 표준 규격(160%대)으로 선명하게 공간 배치되도록 수정했습니다. 2. 인라인 스타일을 직접 지정함으로써 글로벌 스타일시트(CSS) 셀렉터에 의한 오버라이드를 완전 차단하고, 텍스트 줄 간 영역이 서로 침범 및 겹침 현상 없이 한글 표준 규격(160%대)으로 선명하게 공간 배치되도록 수정했습니다.
### 2.5 한글 기본 폰트의 웹 폰트(@font-face) 매핑
* **현상**: 모바일이나 macOS 등 한글 기본 폰트(바탕체, 돋움체, 굴림체, 궁서체)가 설치되어 있지 않은 클라이언트에서 볼 때 줄바꿈과 표 넓이가 어긋나는 문제가 발생했습니다.
* **조치**:
1. `views/main/css/font.css`에 구글 Noto Serif KR 및 Noto Sans KR 웹 폰트를 임포트했습니다.
2. 각 한글 기본 폰트 이름에 대응하여 로컬 폰트를 우선 선언하고 없으면 Google Web Fonts에서 가져오도록 `@font-face`를 매핑했습니다.
### 2.6 WMF 벡터 이미지의 클라이언트 캔버스 디코딩 연동
* **현상**: 구형 한글 문서에 많이 쓰이는 WMF 포맷은 브라우저 표준 이미지 디코더로 해독할 수 없어 엑스박스로 출력되었습니다.
* **조치**:
1. SheetJS의 `wmf.js` 라이브러리를 로컬 `libs/wmf.js`에 다운로드하고 `main.html`에 로드하였습니다.
2. `drawShape` 실행 시 이미지 확장자가 `wmf`인 경우 `WMF.draw_canvas` API로 메모리 내에 Canvas를 생성해 렌더링한 후, PNG Data URL(`canvas.toDataURL('image/png')`)로 추출해 `backgroundImage`에 바인딩하도록 수정했습니다.
### 2.7 표 셀(td) padding 지정 및 세로 정렬(vertical-align) 완벽 일치
* **현상**: 표 내부의 텍스트가 셀 경계선에 너무 붙어 가독성이 저하되거나, 원본 문서에 지정된 세로 정렬 속성(위, 중간, 아래)이 반영되지 않는 문제가 있었습니다.
* **조치**:
1. `libs/hwp.js``visitCellListHeader` 파서에서 셀 버퍼 데이터로부터 **세로 정렬 바이트(vertical-align: 0-상단, 1-중앙, 2-하단)**를 추가로 해독하여 전달하도록 수정했습니다.
2. `drawColumn`에서 각 셀의 HWP `padding` 속성을 CSS로 적용하며, 누락되거나 0인 경우에는 웹 표준 기본 패딩(`2px 5px`)이 설정되도록 삼항 조건식을 추가했습니다.
3. 해독된 정렬 바이트를 CSS `vertical-align` 속성과 매핑시켜 인라인 스타일에 강제 반영했습니다.
### 2.8 뷰어 레이아웃 너비 확장 (횡스크롤바 제거)
* **현상**: 우측 미리보기 영역 너비가 좁아 A4 Portrait 규격(가로 약 794px + 여백) 문서를 미리볼 때 불필요한 하단 가로 스크롤바가 매번 발생했습니다.
* **조치**:
1. `views/main/css/style_archive.css`의 우측 미리보기 컨테이너(`.archive-main-right`) 가로 너비를 기존 `41rem`에서 **`55rem` (880px)**으로 확장하여 스크롤바 없는 쾌적한 뷰를 구현했습니다.
2. 이에 맞춰 우측 영역과 인접한 토글 스위치 및 버튼 메뉴들의 우측 절대 좌표 위치(`right: 41.5rem` -> **`right: 55.5rem`**)를 상향 조정했습니다.
### 2.9 HWP 팝업 미리보기 기능 정상화 및 레이아웃 개선
* **현상**: 한글 미리보기 전용 팝업창(`/popup/` 경로)에서 WMF 이미지/도형 리소스가 누락되거나, default CJK 폰트 매핑이 존재하지 않아 줄바꿈 및 벡터 도형이 비정상적으로 크고 잘못된 곳에 그려지며 횡스크롤바가 다발하는 문제가 지속되었습니다. 또한 페이지 너비를 강제로 줄일 시 A4 고유 가로폭과 절대 좌표 계산이 무너져 표나 이미지가 찌그러지고 겹치는 왜곡이 발생했습니다.
* **조치**:
1. `views/main/popup.html` 파일 내부에서 `hwp.js`가 구동되기 전 웹 폰트 매핑 스타일시트(`/main/css/font.css`)와 WMF 벡터 캔버스 렌더러 스크립트(`/libs/wmf.js`)를 반드시 추가 로드하도록 수정했습니다.
2. `popup.js`, `pageRenderer.js`, `docPageRenderer.js` 각 한글 미리보기 영역의 동적 CSS 주입(`styleEl.textContent`) 블록 내에 `.hwp-inner-container`의 최대 너비를 **`950px`**로 확장하였습니다.
3. 페이지 요소(`div[data-page-number]`)에 일시적으로 인젝션했던 `max-width: 100%``box-sizing: border-box` 설정을 완전 소거하여, 한글 고유의 A4 정밀 레이아웃 비율이 파괴되거나 표가 깨지는 문제를 방지하고 원래의 정상적인 형태로 복구시켰습니다.
4. `.hwpjs-viewer > div` 영역에 `overflow-x: hidden !important` 스타일만을 입혀 불필요한 횡스크롤바의 노출을 제어하고, `libs/hwp.js` 내에서 `vertRelTo === 0` (종이) 및 `1` (쪽) 절대 배치 도형 렌더링 시 부모 페이지의 padding(마진) 값만큼을 좌표에서 빼주어 도형들이 밀려나지 않고 표 바로 밑의 지정된 본문 영역 안에 안착하도록 보정했습니다.
--- ---
## 3. 구현 코드 및 로직 명세 (libs/hwp.js) ## 3. 구현 코드 및 로직 명세 (libs/hwp.js)
@@ -91,15 +132,13 @@ value: function visitBinData(record) {
// GIF: 47 49 46 38 // GIF: 47 49 46 38
else if (payload[0] === 0x47 && payload[1] === 0x49 && payload[2] === 0x46 && payload[3] === 0x38) isRawImage = true; else if (payload[0] === 0x47 && payload[1] === 0x49 && payload[2] === 0x46 && payload[3] === 0x38) isRawImage = true;
// WMF: D7 CD C6 9A // WMF: D7 CD C6 9A
else if (payload[0] === 0xD7 && payload[1] === 0xCD && payload[2] === 0xC6 && payload[3] === 0x9A) isRawImage = true; else if (payload[0] === 0x7D && payload[1] === 0xCD && payload[2] === 0xC6 && payload[3] === 0x9A) isRawImage = true;
} }
var decompressed; var decompressed;
if (isRawImage) { if (isRawImage) {
// 압축 해제 없이 raw 바이너리 복사
decompressed = payload; decompressed = payload;
} else { } else {
// 2단계 다중 압축 해제 시도 (zlib 윈도우 비트 -15 -> 표준 -> raw 순서)
try { try {
decompressed = pako_1.inflate(payload, { windowBits: -15 }); decompressed = pako_1.inflate(payload, { windowBits: -15 });
} catch (e1) { } catch (e1) {
@@ -109,7 +148,7 @@ value: function visitBinData(record) {
try { try {
decompressed = pako_1.inflateRaw(payload); decompressed = pako_1.inflateRaw(payload);
} catch (e3) { } catch (e3) {
decompressed = payload; // 최종 폴백 decompressed = payload;
} }
} }
} }
@@ -121,82 +160,106 @@ value: function visitBinData(record) {
} }
``` ```
### 3.2 도형 태그 식별을 위한 파서 해석 추가 (`visit`) ### 3.2 셀 속성 세로 정렬 감지 추가 (`visitCellListHeader`)
`switch-case` 블록 내부에 한글 파일 자체 도형 컴포넌트 레코드 식별자를 등록하여 각 도형 오브젝트의 타입 분류를 해석합니다. 표 내부 셀(Cell)의 크기 및 패딩에 더하여 세로 정렬(상단/중앙/하단) 옵션의 바이트 코드를 감지하는 구조입니다.
```javascript ```javascript
switch (record.tagID) { key: "visitCellListHeader",
// ... 기존 공통 케이스 ... value: function visitCellListHeader(reader) {
case SectionTagID.HWPTAG_SHAPE_COMPONENT_PICTURE: var option = {
{ column: reader.readUInt16(),
this.visitPicture(record, control); row: reader.readUInt16(),
break; colSpan: reader.readUInt16(),
rowSpan: reader.readUInt16(),
width: reader.readUInt32(),
height: reader.readUInt32(),
padding: [reader.readUInt16(), reader.readUInt16(), reader.readUInt16(), reader.readUInt16()]
};
if (!reader.isEOF()) {
option.borderFillID = reader.readUInt16() - 1;
} }
// 추가 반영된 개별 도형 파싱 케이스
case SectionTagID.HWPTAG_SHAPE_COMPONENT_RECTANGLE: if (!reader.isEOF()) {
{ reader.readUInt8(); // text flow 건너뛰기
if (isShape(control)) {
control.type = CommonCtrlID.Rectangle;
} }
break;
} if (!reader.isEOF()) {
case SectionTagID.HWPTAG_SHAPE_COMPONENT_ELLIPSE: option.verticalAlign = reader.readUInt8(); // 0: Top, 1: Middle, 2: Bottom
{
if (isShape(control)) {
control.type = CommonCtrlID.Ellipse;
}
break;
}
case SectionTagID.HWPTAG_SHAPE_COMPONENT_LINE:
{
if (isShape(control)) {
control.type = CommonCtrlID.Line;
}
break;
}
case SectionTagID.HWPTAG_SHAPE_COMPONENT_ARC:
{
if (isShape(control)) {
control.type = CommonCtrlID.Arc;
}
break;
}
case SectionTagID.HWPTAG_SHAPE_COMPONENT_POLYGON:
{
if (isShape(control)) {
control.type = CommonCtrlID.Polygon;
}
break;
}
case SectionTagID.HWPTAG_SHAPE_COMPONENT_CURVE:
{
if (isShape(control)) {
control.type = CommonCtrlID.Curve;
}
break;
} }
return option;
} }
``` ```
### 3.3 이미지 및 도형 요소 스타일 렌더링 (`drawShape`) ### 3.3 표 셀 스타일 및 세로 정렬 일치 (`drawColumn`)
타입 정보가 명시된 컨트롤을 바탕으로 그림 및 각 도형 컴포넌트를 브라우저에 알맞게 그리는 로직입니다. 각 td 셀 요소에 여백 수치를 변환하여 할당하고 세로 정렬 스타일을 적용하는 코드입니다.
```javascript
key: "drawColumn",
value: function drawColumn(container, paragraphList) {
var _this = this;
var column = document.createElement('td');
var attrib = paragraphList.attribute;
column.style.width = "".concat(attrib.width / 100, "pt");
column.style.height = "".concat(attrib.height / 100, "pt");
column.colSpan = attrib.colSpan;
column.rowSpan = attrib.rowSpan;
// 여백(padding) 스타일 매핑 & 강제 주입
var pad = attrib.padding || [0, 0, 0, 0];
var ptLeft = pad[0] > 0 ? (pad[0] / 100) + "pt" : "5px";
var ptRight = pad[1] > 0 ? (pad[1] / 100) + "pt" : "5px";
var ptTop = pad[2] > 0 ? (pad[2] / 100) + "pt" : "2px";
var ptBottom = pad[3] > 0 ? (pad[3] / 100) + "pt" : "2px";
column.style.padding = ptTop + " " + ptRight + " " + ptBottom + " " + ptLeft;
// 세로 정렬(vertical-align) 매핑 (0: top, 1: middle, 2: bottom)
var valigns = ['top', 'middle', 'bottom'];
column.style.verticalAlign = valigns[attrib.verticalAlign] || 'middle';
this.drawBorderFill(column, attrib.borderFillID);
paragraphList.items.forEach(function (paragraph) {
_this.drawParagraph(column, paragraph);
});
container.appendChild(column);
}
```
### 3.4 이미지 WMF 캔버스 디코딩 및 도형 SVG 렌더링 & 절대 배치 (`drawShape`)
타입 정보가 명시된 컨트롤을 바탕으로 그림(WMF 대응 포함) 및 각 도형 컴포넌트를 브라우저에 배경 레이어로 알맞게 그리는 로직입니다.
```javascript ```javascript
key: "drawShape", key: "drawShape",
value: function drawShape(container, control) { value: function drawShape(container, control) {
var _this3 = this; var _this3 = this;
var shapeGroup = document.createElement('div'); var shapeGroup = document.createElement('div');
shapeGroup.style.width = "".concat(control.width / 100, "pt"); shapeGroup.style.width = "".concat(control.width / 100, "pt");
shapeGroup.style.height = "".concat(control.height / 100, "pt"); shapeGroup.style.height = "".concat(control.height / 100, "pt");
// 위치 스타일 세팅 (절대 좌표 및 여백) var targetContainer = container;
if (control.attribute.vertRelTo === 0) { if (control.attribute.vertRelTo === 2) {
// 문단(Paragraph) 기준 절대 배치
container.style.position = 'relative';
shapeGroup.style.position = 'absolute'; shapeGroup.style.position = 'absolute';
shapeGroup.style.top = "".concat(control.verticalOffset / 100, "pt"); shapeGroup.style.top = "".concat(control.verticalOffset / 100, "pt");
shapeGroup.style.left = "".concat(control.horizontalOffset / 100, "pt"); shapeGroup.style.left = "".concat(control.horizontalOffset / 100, "pt");
} else if (control.attribute.vertRelTo === 0 || control.attribute.vertRelTo === 1) {
// 종이(Paper) / 쪽(Page) 기준 절대 배치
shapeGroup.style.position = 'absolute';
shapeGroup.style.top = "".concat(control.verticalOffset / 100, "pt");
shapeGroup.style.left = "".concat(control.horizontalOffset / 100, "pt");
if (container.parentNode) {
targetContainer = container.parentNode;
}
} else { } else {
// 문자 단위 등 기타 여백 배치
shapeGroup.style.marginTop = "".concat(control.verticalOffset / 100, "pt"); shapeGroup.style.marginTop = "".concat(control.verticalOffset / 100, "pt");
shapeGroup.style.marginLeft = "".concat(control.horizontalOffset / 100, "pt"); shapeGroup.style.marginLeft = "".concat(control.horizontalOffset / 100, "pt");
} }
@@ -206,7 +269,6 @@ value: function drawShape(container, control) {
shapeGroup.style.display = 'inline-block'; shapeGroup.style.display = 'inline-block';
if (isPicture(control)) { if (isPicture(control)) {
// [1] 이미지(그림) 렌더링 분기
var image = this.hwpDocument.info.binData[control.info.binID]; var image = this.hwpDocument.info.binData[control.info.binID];
if (!image || !image.payload || image.payload.length === 0) { if (!image || !image.payload || image.payload.length === 0) {
shapeGroup.style.border = '1px dashed #aaaaaa'; shapeGroup.style.border = '1px dashed #aaaaaa';
@@ -224,134 +286,110 @@ value: function drawShape(container, control) {
shapeGroup.appendChild(placeholder); shapeGroup.appendChild(placeholder);
} else { } else {
var uint8Arr = image.payload instanceof Uint8Array ? image.payload : new Uint8Array(image.payload); var uint8Arr = image.payload instanceof Uint8Array ? image.payload : new Uint8Array(image.payload);
var blob = new Blob([uint8Arr], {
type: image.extension === 'jpg' ? 'image/jpeg' : "image/".concat(image.extension) // WMF 디코딩 처리
}); if (image.extension.toLowerCase() === 'wmf' && typeof WMF !== 'undefined') {
var imageURL = window.URL.createObjectURL(blob); try {
var size = WMF.image_size(uint8Arr);
var canvas = document.createElement('canvas');
canvas.width = size[0] || (control.width / 10);
canvas.height = size[1] || (control.height / 10);
WMF.draw_canvas(uint8Arr, canvas);
var imageURL = canvas.toDataURL('image/png');
shapeGroup.style.backgroundImage = "url(\"".concat(imageURL, "\")"); shapeGroup.style.backgroundImage = "url(\"".concat(imageURL, "\")");
shapeGroup.style.backgroundRepeat = 'no-repeat'; shapeGroup.style.backgroundRepeat = 'no-repeat';
shapeGroup.style.backgroundPosition = 'center'; shapeGroup.style.backgroundPosition = 'center';
shapeGroup.style.backgroundSize = 'contain'; shapeGroup.style.backgroundSize = 'contain';
} catch (wmfErr) {
console.error("[HWP.js Debug] WMF rendering error:", wmfErr);
shapeGroup.style.border = '1px solid red';
}
} else {
var blob = new Blob([uint8Arr], {
type: image.extension === 'jpg' ? 'image/jpeg' : "image/".concat(image.extension)
});
var imageURL = window.URL.createObjectURL(blob);
shapeGroup.style.backgroundImage = "url(\"".concat(imageURL, "\")");
shapeGroup.style.backgroundRepeat = 'no-repeat';
shapeGroup.style.backgroundPosition = 'center';
shapeGroup.style.backgroundSize = 'contain';
}
} }
} else { } else {
// [2] 자체 제작 도형 렌더링 분기
shapeGroup.style.boxSizing = 'border-box'; shapeGroup.style.boxSizing = 'border-box';
shapeGroup.style.position = 'relative';
var svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
svg.setAttribute('width', '100%');
svg.setAttribute('height', '100%');
svg.style.position = 'absolute';
svg.style.top = '0';
svg.style.left = '0';
svg.style.pointerEvents = 'none';
svg.style.zIndex = '-1';
if (control.type === CommonCtrlID.Rectangle) { if (control.type === CommonCtrlID.Rectangle) {
// 직사각형 외곽 테두리 및 옅은 배경 var rect = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
shapeGroup.style.border = '1.5px solid #333333'; rect.setAttribute('x', '1.5');
shapeGroup.style.backgroundColor = 'rgba(0, 0, 0, 0.02)'; rect.setAttribute('y', '1.5');
rect.setAttribute('width', 'calc(100% - 3px)');
rect.setAttribute('height', 'calc(100% - 3px)');
rect.setAttribute('fill', 'rgba(0, 0, 0, 0.02)');
rect.setAttribute('stroke', '#333333');
rect.setAttribute('stroke-width', '1.5');
svg.appendChild(rect);
} else if (control.type === CommonCtrlID.Ellipse) { } else if (control.type === CommonCtrlID.Ellipse) {
// 타원형 및 둥근 모서리 처리 var ellipse = document.createElementNS('http://www.w3.org/2000/svg', 'ellipse');
shapeGroup.style.border = '1.5px solid #333333'; ellipse.setAttribute('cx', '50%');
shapeGroup.style.borderRadius = '50%'; ellipse.setAttribute('cy', '50%');
shapeGroup.style.backgroundColor = 'rgba(0, 0, 0, 0.02)'; ellipse.setAttribute('rx', 'calc(50% - 1.5px)');
ellipse.setAttribute('ry', 'calc(50% - 1.5px)');
ellipse.setAttribute('fill', 'rgba(0, 0, 0, 0.02)');
ellipse.setAttribute('stroke', '#333333');
ellipse.setAttribute('stroke-width', '1.5');
svg.appendChild(ellipse);
} else if (control.type === CommonCtrlID.Line) { } else if (control.type === CommonCtrlID.Line) {
// 선 객체의 세로/가로 두께 정렬 var line = document.createElementNS('http://www.w3.org/2000/svg', 'line');
var w = control.width / 100; line.setAttribute('x1', '0');
var h = control.height / 100; line.setAttribute('y1', '0');
if (h < 5) { line.setAttribute('x2', '100%');
shapeGroup.style.borderTop = '1.5px solid #333333'; line.setAttribute('y2', '100%');
} else if (w < 5) { line.setAttribute('stroke', '#333333');
shapeGroup.style.borderLeft = '1.5px solid #333333'; line.setAttribute('stroke-width', '1.5');
} else { svg.appendChild(line);
shapeGroup.style.border = '1px solid #333333';
}
} else if (control.type === CommonCtrlID.Arc || control.type === CommonCtrlID.Polygon || control.type === CommonCtrlID.Curve) { } else if (control.type === CommonCtrlID.Arc || control.type === CommonCtrlID.Polygon || control.type === CommonCtrlID.Curve) {
// 다각형, 호, 자유곡선의 흐린 테두리 가이드 var placeholder = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
shapeGroup.style.border = '1px dashed #555555'; placeholder.setAttribute('x', '1');
shapeGroup.style.backgroundColor = 'rgba(0, 0, 0, 0.01)'; placeholder.setAttribute('y', '1');
placeholder.setAttribute('width', 'calc(100% - 2px)');
placeholder.setAttribute('height', 'calc(100% - 2px)');
placeholder.setAttribute('fill', 'rgba(0, 0, 0, 0.01)');
placeholder.setAttribute('stroke', '#555555');
placeholder.setAttribute('stroke-width', '1');
placeholder.setAttribute('stroke-dasharray', '4');
svg.appendChild(placeholder);
} else { } else {
shapeGroup.style.border = '1px solid #cccccc'; var defaultRect = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
shapeGroup.style.backgroundColor = 'rgba(0, 0, 0, 0.01)'; defaultRect.setAttribute('x', '1');
defaultRect.setAttribute('y', '1');
defaultRect.setAttribute('width', 'calc(100% - 2px)');
defaultRect.setAttribute('height', 'calc(100% - 2px)');
defaultRect.setAttribute('fill', 'rgba(0, 0, 0, 0.01)');
defaultRect.setAttribute('stroke', '#cccccc');
defaultRect.setAttribute('stroke-width', '1');
svg.appendChild(defaultRect);
} }
shapeGroup.appendChild(svg);
} }
// 도형 내부 Paragraph 텍스트가 정상 레이어링되도록 오버레이 처리
control.content.forEach(function (paragraphList) { control.content.forEach(function (paragraphList) {
paragraphList.items.forEach(function (paragraph) { paragraphList.items.forEach(function (paragraph) {
_this3.drawParagraph(shapeGroup, paragraph); _this3.drawParagraph(shapeGroup, paragraph);
}); });
}); });
container.appendChild(shapeGroup); targetContainer.appendChild(shapeGroup);
}
```
### 3.4 개별 텍스트 스팬 스타일 지정 (`drawText`)
개별 문자열 `div` 스팬 단위로 `line-height`를 인라인 스타일로 직접 주입하여 글로벌 CSS 오버라이드를 해결한 코드입니다.
```javascript
key: "drawText",
value: function drawText(container, paragraph, shapePointer, endPos) {
var _this4 = this;
var range = paragraph.content.slice(shapePointer.pos, endPos + 1);
var texts = [];
var ctrlIndex = 0;
range.forEach(function (hwpChar) {
if (typeof hwpChar.value === 'string') {
texts.push(hwpChar.value);
return;
}
if (hwpChar.type === CharType.Extened) {
var control = paragraph.controls[ctrlIndex];
ctrlIndex += 1;
_this4.drawControl(container, control);
}
if (hwpChar.value === 13) {
texts.push('\n');
}
});
var text = texts.join('');
var span = document.createElement('div');
span.textContent = text;
span.style.lineHeight = '1.65'; // 인라인 줄 간격 직접 부여하여 영역 겹침 현상 원천 차단
var charShape = this.hwpDocument.info.getCharShpe(shapePointer.shapeIndex);
if (charShape) {
var fontBaseSize = charShape.fontBaseSize,
fontRatio = charShape.fontRatio,
color = charShape.color,
fontId = charShape.fontId;
var fontSize = fontBaseSize * (fontRatio[0] / 100);
span.style.fontSize = "".concat(fontSize, "pt");
span.style.lineBreak = 'anywhere';
span.style.whiteSpace = 'pre-wrap';
span.style.color = this.getRGBStyle(color);
var fontFace = this.hwpDocument.info.fontFaces[fontId[0]];
span.style.fontFamily = fontFace.getFontFamily();
}
container.appendChild(span);
}
```
### 3.5 텍스트 단락 줄 간격 지정 (`drawParagraph`)
단락 컨테이너 생성 시 줄 간격을 쾌적하게(1.65배) 렌더링하는 코드 부분입니다.
```javascript
key: "drawParagraph",
value: function drawParagraph(container, paragraph) {
var _this5 = this;
var paragraphContainer = document.createElement('div');
paragraphContainer.style.margin = '0';
paragraphContainer.style.lineHeight = '1.65'; // 부모 단락에도 줄 간격 지정
var shape = this.hwpDocument.info.paragraphShapes[paragraph.shapeIndex];
paragraphContainer.style.textAlign = TEXT_ALIGN[shape.align];
paragraph.shapeBuffer.forEach(function (shapePointer, index) {
var endPos = paragraph.getShapeEndPos(index);
_this5.drawText(paragraphContainer, paragraph, shapePointer, endPos);
});
container.append(paragraphContainer);
} }
``` ```
@@ -359,8 +397,12 @@ value: function drawParagraph(container, paragraph) {
## 4. 디버깅 및 유지보수 가이드 ## 4. 디버깅 및 유지보수 가이드
1. **클라이언트 캐시 문제:** 1. **클라이언트 캐시 문제**:
* 소스 파일인 `hwp.js`가 업데이트된 후에도 브라우저가 이전 라이브러리를 캐싱하고 있다면 이미지/도형이 보이지 않거나 줄 간격이 좁게 유지됩니다. 테스트 전에는 반드시 **`Ctrl + F5`** 또는 개발자 도구의 **`Disable cache`** 옵션을 활성화하여 확인을 진행하십시오. * 소스 파일인 `hwp.js`나 스타일시트가 업데이트된 후에도 브라우저가 이전 파일을 캐싱하고 있다면 레이아웃 변경이 보이지 않거나 스크롤이 유지됩니다. 테스트 전에는 반드시 **`Ctrl + F5`** 또는 개발자 도구의 **`Disable cache`** 옵션을 활성화하여 확인을 진행하십시오.
2. **WMF/EMF 벡터 포맷 추가 제언:** 2. **폰트 로딩**:
* 현재 웹 표준 규격(`PNG`, `JPEG`, `GIF`) 이미지는 프론트엔드 내에서 완전하게 디코딩됩니다. * CDN을 통해 가져오는 구글 Noto 웹 폰트가 차단된 특수 오프라인(망분리) 환경인 경우, 폰트 파일(`woff2`)을 로컬 서버 `views/main/css/font/` 경로 등에 직접 배치하고 `font.css` 파일에서 경로명을 상대경로로 기입하여 로딩해야 합니다.
* 향후 다수의 WMF/EMF 포맷에 대한 정교한 벡터 렌더링이 필요한 경우, `wmf.js` 라이브러리를 연동하거나, 가이드 3절의 백엔드 경량 이미지 변환 API(`magick convert` 등)를 통해 웹 표준 이미지(`WebP`/`PNG`)로 변환하여 렌더링하도록 확장 대응이 가능합니다. 3. **가로/세로 혼용 문서의 가로 스크롤 조치**:
* 구역 설정으로 인해 세로형 문서 중간에 가로 방향(Landscape) 페이지가 포함될 경우, 해당 가로 페이지는 구역 크기에 비례하여 화면에 훨씬 넓게 그려집니다.
* 기본적으로 뷰어 컨테이너에 `overflow: auto`가 설정되어 가로 스크롤바가 자동 제공되나, 모바일 뷰 등을 고려하여 한 화면에 맞추는 기능이 요구될 경우 JavaScript 단에서 `transform: scale(비율)` 스타일을 동적으로 입혀 축소 렌더링하는 반응형 고도화 적용이 가능합니다.
---

View File

@@ -0,0 +1,136 @@
## 1. 기능 한계 원인 분석 및 최선 대응 방안
본 섹션은 클라이언트 사이드 HWP 미리보기가 원본 문서와 완전히 일치하지 못하는 **구조적·기술적 원인**을 분석하고, 각 한계에 대해 현실적으로 취할 수 있는 **최선의 대응 방안**을 정리합니다.
---
### 1.1 한계의 근본 원인: HWP 포맷의 비공개 독점 규격
HWP(한글과컴퓨터 워드프로세서) 파일 포맷은 **공개된 국제 표준(ISO/IEC)이 아닌 한컴의 독점 바이너리 규격**입니다.
* **공개 문서의 한계**: 한컴은 일부 규격 문서(`HWP 5.0 File Format Specification`)를 공개했으나, 이는 전체 규격의 약 60~70% 수준이며 도형(Shape) 세부 속성, 필드 코드, 고급 서식 등 대부분의 복잡한 요소는 문서화되지 않았습니다.
* **버전별 파편화**: HWP 파일은 버전(97/2002/2007/2010/2022 등)마다 내부 구조가 다르며, 버전 간 호환성 처리가 매우 복잡합니다.
* **오픈소스 파서의 한계**: 현재 사용 중인 `hwp.js`(버전 0.0.3)는 커뮤니티 기반의 역공학(Reverse Engineering) 결과물로, 기본적인 텍스트·표·이미지만 처리하며 고급 기능 구현이 부재합니다.
> **결론**: 브라우저 단에서 네이티브 한글 프로그램 수준의 완벽한 재현은 현재 기술로는 구조적으로 불가능합니다.
---
### 1.2 항목별 한계 원인 및 대응 방안
#### ① 도형(Shape) 위치 및 크기 오차
| 구분 | 내용 |
|---|---|
| **한계** | HWP 도형의 절대 좌표는 `vertRelTo`(세로 기준), `horzRelTo`(가로 기준) 등 복합적인 기준점 체계로 계산되며, 기준점이 문단·쪽·종이·단·표·셀 등 9가지 이상. 이 모든 경우를 역공학으로 완전 파악하기 불가능 |
| **현재 대응** | `vertRelTo` 0(종이), 1(쪽), 2(문단) 3가지 케이스만 구현. DOM 트리 상위 탐색으로 실제 페이지 요소 발굴 후 좌표 배치 |
| **최선 대응** | ① 현재 구현 유지(오차는 있으나 대략적 위치는 맞음) ② 도형이 매우 중요한 경우 **서버 사이드 변환(LibreOffice → PDF)** 방식으로 전환 |
#### ② 선(Line) 도형 방향 오차
| 구분 | 내용 |
|---|---|
| **한계** | HWP Line 도형은 bounding box 안에서의 정확한 시작점(x1,y1)·끝점(x2,y2) 좌표를 별도 payload에 저장하는데, `hwp.js` 파서는 이 payload를 파싱하지 않음 |
| **현재 대응** | bounding box의 가로/세로 비율(aspect ratio)로 방향을 **추론**: 비율 < 0.15 → 수평선, 비율 > 6.5 → 수직선, 그 외 → 대각선 |
| **최선 대응** | ① 현재 추론 방식 유지 (대부분의 실용적 케이스 커버) ② `hwp.js` 파서를 포크(fork)하여 `ShapeComponent` payload의 `startX/Y`, `endX/Y` 필드를 직접 파싱하는 고도화 개발 |
#### ③ 이미지(Picture) 렌더링 실패 시 텍스트 유실
| 구분 | 내용 |
|---|---|
| **한계** | HWP 문서에서 이미지와 텍스트가 같은 문단(Paragraph)에 혼재할 때, 이미지 파싱 실패 시 JavaScript `forEach` 루프가 중단되어 이후 텍스트가 통째로 누락 |
| **현재 대응** | `drawControl()` 호출을 `try-catch`로 감싸 오류를 무시하고 나머지 텍스트를 계속 처리하도록 방어 코드 추가 |
| **최선 대응** | ① 현재 방어 코드 유지 ② 이미지 로드 실패 시 `[그림 영역]` placeholder를 표시하여 레이아웃 유지 |
#### ④ WMF/EMF 벡터 이미지 렌더링 품질
| 구분 | 내용 |
|---|---|
| **한계** | WMF(Windows Metafile)는 브라우저 표준 이미지 포맷이 아닌 Windows 전용 벡터 포맷. `wmf.js`로 Canvas 변환하면 일부 GDI 명령어가 누락되거나 색상·선 굵기가 다르게 표현됨 |
| **현재 대응** | SheetJS의 `wmf.js`로 Canvas 렌더링 후 PNG DataURL로 변환하여 `backgroundImage`에 바인딩 |
| **최선 대응** | ① 현재 방식 유지 ② WMF가 매우 중요한 경우 서버에서 `LibreOffice` 또는 `ImageMagick`으로 SVG/PNG 변환 후 제공 |
#### ⑤ 복잡한 표(Table) 레이아웃 재현 한계
| 구분 | 내용 |
|---|---|
| **한계** | HWP 표의 셀 병합(merge), 대각선 테두리, 배경 그라데이션, 표 캡션, 표 안의 표(nested table) 등 고급 서식이 파서에서 누락됨 |
| **현재 대응** | 기본 셀 너비/높이/패딩/테두리/세로정렬을 파싱하여 `<table><tr><td>` 구조로 렌더링 |
| **최선 대응** | ① 현재 방식으로 대부분의 단순 표는 재현 가능 ② 복잡한 표 서식이 필수인 경우 서버 사이드 변환 방식 권고 |
#### ⑥ 폰트 장평(Font Metrics) 불일치로 인한 줄바꿈 차이
| 구분 | 내용 |
|---|---|
| **한계** | 한글 문서의 줄바꿈은 한컴 전용 폰트 렌더러의 글자 너비 계산값 기준. 웹 폰트(Noto KR 등)는 같은 포인트 크기라도 글자 너비가 미세하게 달라 줄바꿈 위치가 틀어짐 |
| **현재 대응** | `@font-face`로 한글 기본 폰트(바탕, 돋움 등)를 Noto KR 웹 폰트에 매핑하여 최대한 근접하게 표시 |
| **최선 대응** | ① 서버에 한컴 폰트(HCR 폰트 등)를 woff2로 변환하여 직접 서빙 ② 완벽한 재현이 필요하면 서버 사이드 PDF 변환 방식 필수 |
---
### 1.3 대응 방식 비교: 클라이언트 사이드 vs 서버 사이드
현재 구현은 **클라이언트 사이드** 방식입니다. 아래는 두 방식의 장단점 비교입니다.
| 항목 | 클라이언트 사이드 (현재) | 서버 사이드 (LibreOffice/PDF 변환) |
|---|---|---|
| **재현 정확도** | ★★☆☆☆ (기본 요소만 재현) | ★★★★☆ (원본에 매우 근접) |
| **서버 부하** | 없음 (브라우저가 처리) | 높음 (변환 프로세스 실행) |
| **응답 속도** | 빠름 (별도 변환 없음) | 느림 (변환 후 전달) |
| **오프라인 지원** | 가능 | 불가 |
| **유지보수 비용** | 높음 (파서 직접 관리) | 낮음 (LibreOffice가 처리) |
| **도형/수식/고급서식** | ❌ 미지원 | ✅ 대부분 지원 |
| **서버 요구사항** | 없음 | LibreOffice 설치 필요 |
---
### 1.4 최선 대응 전략 권고
현재 PM 시스템의 **HWP 미리보기 목적**(`첨부 파일의 대략적인 내용 확인`)을 고려한 권고안입니다.
#### 전략 A: 현재 방식 유지 + 점진적 개선 (권고)
```
현재 클라이언트 사이드 방식 유지
├─ 텍스트/표/이미지 기본 재현 가능
├─ 서버 부하 없음
├─ 복잡한 도형/수식 → "[그림 영역]" placeholder로 표시
└─ 사용자에게 "정확한 확인은 원본 다운로드" 안내 제공
```
#### 전략 B: 하이브리드 방식 (장기 권고)
```
1차 시도: 클라이언트 사이드 hwp.js 렌더링 (빠름)
↓ 렌더링 오류 또는 복잡한 문서 감지 시
2차 폴백: "PDF로 보기" 버튼으로 서버 사이드 변환 안내
↓ 서버에서 LibreOffice를 통해 HWP → PDF 변환
PDF 뷰어로 고품질 렌더링 제공
```
> 현재 시스템에는 이미 **"PDF로 보기"** 폴백 버튼이 구현되어 있어 전략 B의 기반은 갖춰진 상태입니다.
#### 전략 C: 완전 서버 사이드 전환 (고정밀도 요구 시)
```
HWP 파일 업로드
→ 서버(LibreOffice headless)에서 HWP → PDF/SVG 변환
→ 변환된 PDF를 PDF.js로 고품질 렌더링
```
* **장점**: 원본 문서와 가장 높은 일치율 보장
* **단점**: 서버에 LibreOffice 설치 및 운영 필요, 변환 시간 소요(파일당 2~10초)
---
### 1.5 사용자 안내 메시지 권고
미리보기와 원본 문서 간 차이가 있을 수 있음을 사용자에게 명확히 안내하는 것이 중요합니다.
```
💡 한글 문서(.hwp) 미리보기는 브라우저에서 직접 렌더링하는 방식으로,
복잡한 도형·수식·특수 서식은 원본과 다르게 표시될 수 있습니다.
정확한 내용 확인이 필요한 경우 [파일 다운로드] 또는 [PDF로 보기]를 이용해 주세요.
```

View File

@@ -0,0 +1,117 @@
# 한글 파일(HWP) 미리보기 렌더링 고도화 작업 내역서
본 문서는 웹 브라우저(클라이언트) 환경에서 한글 문서(`.hwp`) 파일을 직접 파싱하고 렌더링하는 클라이언트 사이드 미리보기 엔진의 고도화 작업 및 디버깅 수행 내역을 상세히 기록한 문서입니다.
---
## 1. 주요 작업
클라이언트 뷰어 라이브러리(`libs/hwp.js`) 및 스타일시트, HTML 로드 방식을 개선하여 원본 문서와의 레이아웃 일치율을 비약적으로 끌어올리고 깨진 리소스(이미지/도형)를 복구하는 것을 목표로 하였습니다.
* **바이너리 압축 해제 및 이미지 MIME 안정화**: 무압축 Raw 이미지 파싱 우회 및 다단계 압축해제 예외처리 폴백 구축, 오타로 인한 이미지 로딩 에러(images/png) 수정.
* **텍스트 영역 겹침 및 간격 보정**: 글로벌 CSS 충돌로 인한 텍스트 줄 겹침 결함을 단락 및 스팬 인라인 스타일 강제 부여를 통해 보정.
* **HWP 문서 내 자체 생성 도형(벡터) 복원 및 위치 정밀도 교정**:
- 파싱 대상에서 유실되었던 직사각형, 타원, 선 등의 형상을 SVG 배경 레이어 렌더러로 복구.
- 도형의 기준 위치(`vertRelTo` 세로 기준: 문단/쪽/종이)에 따라 절대 배치(`position: absolute`) 및 좌표 배치를 분기하여, 도형이 정상 범위를 벗어나 페이지 바깥(회색 배경 부분)으로 밀려 나가는 겹침 오류를 완벽히 수정.
* **표(Table) 스타일 및 정렬 정밀 매핑**: 셀 여백(Padding) 스타일 강제 적용 및 HWP 바이너리에 포함된 세로 정렬(Vertical Alignment) 속성을 CSS 속성에 1대1 일치화.
* **한글 기본 폰트 대체 매핑**: 클라이언트 기기에 한양굴림, 한컴바탕 등 오피스 전용 폰트가 설치되어 있지 않을 경우를 대비하여 웹 폰트(Noto Serif/Sans KR)를 연동.
* **WMF 벡터 파일 캔버스 디코딩 연동**: 브라우저 표준 디코더가 읽지 못하는 `.wmf` 벡터 그래픽 파일을 클라이언트 단에서 파싱하여 PNG 이미지로 변환 및 렌더링.
* **뷰어 레이아웃 너비 확장 (가로 스크롤 제거)**:
- 기본 가로 너비가 좁아 A4 문서 미리보기 시 발생하던 불필요한 가로(횡) 스크롤바를 원천 제거하기 위해 우측 미리보기 영역 너비를 확장.
* **팝업 미리보기 창 라이브러리/스타일셋 동기화**:
- 별도 팝업 창(`views/main/popup.html`)에서 한글 미리보기를 열 때 `wmf.js``font.css` 로드 누락으로 인해 도형이 깨지거나 횡스크롤바가 발생하던 문제를 리소스 태그 삽입 및 인라인/클래스 스타일링 규격화로 정정.
---
## 2. 수행 내용
수행된 작업의 구체적인 파일별 수정 상세 명세입니다.
### 2.1 폰트 정의 추가 및 웹 폰트 매핑
* **대상 파일**: [font.css](file:///d:/40.%20개발소스/04.%20PM/pm_ver4/trunk/PM_ver4/views/main/css/font.css)
* **내용**:
- 구글 Noto Sans KR 및 Noto Serif KR 웹 폰트를 CSS 임포트하고 폰트 매핑을 정의했습니다.
### 2.2 WMF 디코더 스크립트 로드 추가
* **대상 파일**: [main.html](file:///d:/40.%20개발소스/04.%20PM/pm_ver4/trunk/PM_ver4/views/main/main.html)
* **내용**:
- `libs/hwp.js`가 로딩되기 전 `wmf.js` 라이브러리를 로드하는 태그를 추가했습니다.
### 2.3 뷰어 레이아웃 너비 확장 및 정렬 보정
* **대상 파일**: [style_archive.css](file:///d:/40.%20개발소스/04.%20PM/pm_ver4/trunk/PM_ver4/views/main/css/style_archive.css)
* **내용**:
- 우측 미리보기 컨테이너(`.archive-main-right`)의 너비를 기존 `41rem`에서 A4 가로폭(약 794px + 여백)이 충분히 수용되는 **`55rem` (880px)**으로 확장하여 가로 스크롤바가 생기지 않도록 수정했습니다.
- 너비 변경에 따라 우측 영역에 맞물려 있던 팝업 메뉴 및 토글 단추 위치(`right: 41.5rem` -> **`right: 55.5rem`**)를 함께 조정했습니다.
### 2.4 HWP 파서 및 렌더러 로직 전면 개선
* **대상 파일**: [hwp.js](file:///d:/40.%20개발소스/04.%20PM/pm_ver4/trunk/PM_ver4/libs/hwp.js)
* **내용**:
#### ① OLE 바이너리 이미지 압축 우회 및 안정화
- 이미지 데이터 파싱 시, 첫 4바이트 Magic Number(PNG, JPEG, GIF, WMF)를 검사하여 압축이 필요 없는 데이터는 드플레트(Decompress) 과정을 생략하고 직접 읽게 하였습니다.
- 압축 해제 실패 시 다단계 폴백 구조(`windowBits: -15` -> `기본 inflate` -> `inflateRaw` -> `원본 raw 복사`)를 구축하여 뷰어 크래시를 차단했습니다.
- MIME 명칭 오타(`"images/"` -> `"image/"`) 및 JPG 규격 명칭(`image/jpeg`)을 보정했습니다.
#### ② 단락 및 텍스트 줄 겹침 결함 수정
- `drawText``drawParagraph` 메서드 내에서 텍스트가 삽입되는 각 `div` 스팬과 컨테이너에 `line-height: 1.65` 인라인 스타일을 적용해 부모 스타일시트에 의해 줄 영역이 위아래로 포개어지는 문제를 예방했습니다.
#### ③ 도형(Rectangle/Ellipse/Line)의 SVG 배경 레이어 렌더링 고도화 및 좌표 보정
- HWP 파서의 `visit` 메서드 내에 직사각형, 타원, 선, 호, 다각형, 자유곡선 레코드 ID들을 식별하도록 등록하고 도형 타입을 구분 지었습니다.
- `drawParagraph` 렌더링 시 문단 `div` 요소를 쪽(Page)에 즉시 추가하여 자식 도형들의 절대 배치 위치 기준(`parentNode`)이 정상적으로 작동하도록 수정했습니다.
- `drawShape` 메서드에서 도형의 세로 기준 설정(`vertRelTo`)을 분석합니다.
- **`vertRelTo === 2` (문단 기준)**: 문단 컨테이너 `div``position: relative` 스타일을 적용하고, 도형을 `position: absolute; top: [verticalOffset]; left: [horizontalOffset];`로 배치하여 본문 내에 정확히 안착시켰습니다.
- **`vertRelTo === 0 또는 1` (종이/쪽 기준)**: 도형을 `position: absolute`로 지정하고 부모 쪽 컨테이너(`container.parentNode`)의 절대 레이어에 직접 덧그려 올바른 위치를 맞췄습니다.
- 이를 통해 기존에 상대 배치 마진(`marginTop`) 누적으로 인해 본문 뒤로 엉뚱하게 밀려 나거나 페이지 바깥 회색 프레임으로 가라앉던 결함을 완벽히 해소했습니다.
#### ④ 표 셀 세로 정렬(vertical-align) 파싱 및 여백(padding) 스타일링
- `visitCellListHeader` 파서 메서드가 셀 레코드 버퍼를 해독할 때, 세로 정렬 바이트(verticalAlign: 0-상단, 1-중앙, 2-하단) 데이터를 추가로 추출하여 전달하도록 개선했습니다.
- `drawColumn` 렌더러가 td 엘리먼트를 조립할 때, 파싱된 셀 padding 값을 CSS 속성으로 가공하고 데이터가 0이거나 유실된 경우에는 가독성을 확보하도록 디폴트 규격 패딩(`padding: 2px 5px;`)을 바인딩했습니다.
- 해독된 세로 정렬 상태 값을 CSS `vertical-align` 속성(`top`, `middle`, `bottom`)과 정확히 일치화시켜 테이블 안의 정렬을 완벽하게 재현했습니다.
#### ⑤ WMF 이미지의 Canvas 드로잉 및 PNG 변환 연동
- 그림 객체(`isPicture`) 렌더링 시 이미지 확장자가 `wmf`인 경우 `wmf.js` API를 연동했습니다.
- WMF 이미지 원본 크기를 측정하여 브라우저 메모리에 가상 `<canvas>` 엘리먼트를 생성해 캔버스에 이미지를 렌더링한 후, `canvas.toDataURL('image/png')`를 추출해 div의 `backgroundImage`에 바인딩했습니다.
### 2.5 팝업 미리보기 리소스 추가 로드 및 스타일셋 개선
* **대상 파일**:
- [popup.html](file:///d:/40.%20개발소스/04.%20PM/pm_ver4/trunk/PM_ver4/views/main/popup.html)
- [popup.js](file:///d:/40.%20개발소스/04.%20PM/pm_ver4/trunk/PM_ver4/views/main/jsm/popup.js)
- [pageRenderer.js](file:///d:/40.%20개발소스/04.%20PM/pm_ver4/trunk/PM_ver4/views/main/jsm/archive/pageRenderer.js)
- [docPageRenderer.js](file:///d:/40.%20개발소스/04.%20PM/pm_ver4/trunk/PM_ver4/views/main/jsm/officialDoc/docPageRenderer.js)
* **내용**:
- `popup.html``font.css` 링크 및 `wmf.js` 스크립트를 추가하여 팝업 뷰어에서도 한글 기본 폰트 대체 및 WMF 벡터 도형이 실시간으로 변환되도록 연동했습니다.
- `popup.js`, `pageRenderer.js`, `docPageRenderer.js` 파일 내의 HWP 렌더링 영역 스타일 인젝션 코드에서 컨테이너의 최대 너비를 **`950px`**로 확장 적용했습니다.
- `.hwpjs-viewer > div` 클래스에 `overflow-x: hidden !important` 스타일을 동적으로 입혀 불필요한 횡스크롤바 생성을 원천 차단하고, `div[data-page-number]` 페이지 요소의 가로폭 축소 유실을 유발하던 `max-width: 100%` 설정을 완전히 제거하여 표와 도형이 찌그러지고 정렬이 어긋나는 레이아웃 파괴 버그를 예방하였습니다.
---
## 3. 검증 결과
수정 파일의 문법적 무결성 및 실제 동작 상태에 대한 검증 내용입니다.
### 3.1 JavaScript 구문 분석 검증 (Syntax Check)
* Node.js 구문 체크 명령어를 통과하여 수정된 `libs/hwp.js` 파일에 어떠한 문법적 에러도 없음을 증명했습니다.
```bash
node -c "libs/hwp.js" => (오류 없이 성공적으로 컴파일 완료)
```
### 3.2 기능적 검증 요약
* **도형 좌표 보정**: 본래 문단 뒤에 겹쳐 있어야 할 동그라미(Ellipse)와 연결선(Line) 등의 구조물들이 페이지 밖으로 탈탈 밀려나던 문제가 완전히 해결되어 본문 표 바로 밑에 원본과 동일하게 정밀 안착하는 것을 확인했습니다.
* **가로 스크롤 소거**: 미리보기 화면을 띄웠을 때 횡스크롤바가 더 이상 나타나지 않으며, A4 규격의 본문과 여백이 전체적으로 여유롭게 한 화면에 들어오는 것을 확인했습니다.
* **이미지 및 테이블**: WMF 이미지 디코딩 및 표의 세로 정렬/여백 등이 모두 원본 문서의 비주얼과 정합성이 유지됩니다.
---
## 4. 조치사항 (운영 및 유지보수 가이드)
프로젝트 운영 단계에서 발생 가능한 상황에 대한 대처 방안입니다.
1. **클라이언트 캐시 갱신 (가장 중요)**
- 백엔드 코드와 달리 프론트엔드 자바스크립트(`hwp.js`, `font.css`) 및 스타일시트(`style_archive.css`) 파일은 클라이언트 브라우저가 강력하게 캐싱합니다.
- 렌더링 개선 내역이 정상적으로 적용되지 않을 경우, 브라우저에서 **`Ctrl + F5`**(강제 새로고침)를 수행하거나 개발자 도구(F12)의 네트워크 탭에서 **'캐시 사용 안 함(Disable Cache)'**을 활성화한 후 재시도하십시오.
2. **폐쇄망(오프라인) 배포 시 폰트 로드 조치**
- 현재 등록된 폰트는 구글 웹 폰트 CDN 주소를 사용합니다. 만약 외부 인터넷 접근이 불가능한 망분리/오폐쇄망 환경에 프로젝트가 배포될 경우, `@import` 주소의 구글 폰트를 가져오지 못해 폰트가 기본형으로 깨질 수 있습니다.
- **조치 방안**: `views/main/css/` 하위에 `fonts/` 디렉토리를 생성하고, `NotoSansKR.woff2`, `NotoSerifKR.woff2` 파일을 서버에 저장한 후 `font.css`에서 상대 경로(`url('./fonts/...')`)로 서빙하도록 경로를 전환해야 합니다.
3. **가로/세로 혼용 문서의 가로 스크롤 조치**
- 구역 설정으로 인해 세로형 문서 중간에 가로 방향(Landscape) 페이지가 포함될 경우, 해당 가로 페이지는 구역 크기에 비례하여 화면에 훨씬 넓게 그려집니다.
- 기본적으로 뷰어 컨테이너에 `overflow: auto`가 설정되어 가로 스크롤바가 자동 제공되나, 모바일 뷰 등을 고려하여 한 화면에 맞추는 기능이 요구될 경우 JavaScript 단에서 `transform: scale(비율)` 스타일을 동적으로 입혀 축소 렌더링하는 반응형 고도화 적용이 가능합니다.