From d13c414d7f0e793548d088716a7e0183bfe9ebe9 Mon Sep 17 00:00:00 2001 From: koj729 Date: Mon, 15 Jun 2026 13:51:06 +0900 Subject: [PATCH] =?UTF-8?q?=ED=8F=B4=EB=8D=94=EB=8B=A8=EC=9C=84=20?= =?UTF-8?q?=EA=B6=8C=ED=95=9C=20=EC=A0=9C=EC=96=B4=20=EA=B8=B0=EB=8A=A5=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 346 ++++++++++++++ auto_start_pm.bat | 21 + controllers/admin/adminController.js | 147 +++++- controllers/archiveController.js | 226 ++++++---- install_docker_wsl.sh | 55 +++ ...5003e35c7a21bd1b2e78bed5f148798-audit.json | 5 + ...b21ac44b7757d5574f2dc85546b9be4-audit.json | 5 + logs/2026-06-12.exception.log | 12 + logs/2026-06-15.error.log | 0 logs/2026-06-15.exception.log | 85 ++++ routes/admin/adminRouter.js | 5 + views/admin/dashboard.html | 421 +++++++++++++++++- views/main/jsm/archive/dataManager.js | 31 +- views/main/jsm/archive/socketManager.js | 30 +- 테이블명세서.html | 64 +++ 15 files changed, 1324 insertions(+), 129 deletions(-) create mode 100644 README.md create mode 100644 auto_start_pm.bat create mode 100644 install_docker_wsl.sh create mode 100644 logs/2026-06-15.error.log create mode 100644 logs/2026-06-15.exception.log diff --git a/README.md b/README.md new file mode 100644 index 0000000..9f9e118 --- /dev/null +++ b/README.md @@ -0,0 +1,346 @@ +# PROJECT MASTER ver 4.0 - 통합 프로젝트 관리 시스템 (PM) + +본 문서는 **PROJECT MASTER ver 4.0 (PM_ver4)** 프로젝트의 실 서버 가동 환경 세팅, 개발자 사전 준비 사항, 그리고 장기적 시스템 유지보수를 위한 운영 관리 가이드를 제공합니다. + +--- + +## 1. 시스템 개요 (System Overview) +**PROJECT MASTER ver 4.0**은 건설 및 엔지니어링 프로젝트를 효율적으로 통합 관리하기 위해 구축된 **웹 기반 프로젝트 관리 시스템(Project Management System)**입니다. +* **지능형 문서 아카이브**: 트리 구조의 폴더/파일 탐색기 및 보안 도면 암호화/압축 다운로드 지원 +* **AI 기반 공문 자동화**: 수/발신 공문서를 자동으로 분석하여 핵심 메타데이터를 구조화하고 요약하는 Google Gemini AI 결합 +* **지도/3D 기반 관제 (GSIM)**: OpenLayers 지도 및 Cesium 3D 엔진을 연계하여 현장 측량 및 모델 시각화 제공 +* **실시간 다중 협업**: Socket.io를 활용한 접속자 간 실시간 마우스 커서 공유 및 시스템 활동 로그 스트리밍 + +--- + +## 2. 전체 구조도 및 아키텍처 (Architecture & Diagrams) + +### 2.1 시스템 데이터 흐름도 +시스템은 클라이언트 브라우저, 백엔드 Express 서버, PostgreSQL RDBMS, Redis(세션/큐), MinIO/Cloudflare R2 오브젝트 스토리지 간의 긴밀한 통합 구조를 가지고 있습니다. + +```mermaid +graph TD + Client[Browser / Client Client] <-->|HTTP / HTTPS / Axios| NodeServer[Node.js Express Server] + Client <-->|Websocket / Socket.io| NodeServer + NodeServer <-->|SQL Queries| Postgres[(PostgreSQL DB)] + NodeServer <-->|PubSub / Job Queue| Redis[(Redis Server)] + NodeServer <-->|Presigned URL / PUT| MinIO[(MinIO / Cloudflare R2)] + NodeServer -.->|Structured Input/Output| Gemini[Google Gemini API] + NodeServer -.->|Local Shell Command| CLI[Local CLI Converters / PDF / CAD] +``` + +### 2.2 백그라운드 비동기 태스크 파이프라인 +무겁고 연산량이 많은 작업은 사용자 응답 속도를 저해하지 않도록 **Redis 기반의 BullMQ 대기열**을 통해 비동기로 처리합니다. + +```mermaid +sequenceDiagram + participant Client as 브라우저 (Client) + participant Server as 백엔드 서버 (Express) + participant Redis as 레디스 큐 (BullMQ) + participant Worker as 백그라운드 워커 (Worker) + participant Storage as 오브젝트 스토리지 (S3/MinIO) + + Client->>Server: 폴더 압축 또는 파일 변환 요청 + Server->>Redis: Job 등록 (convert-pdf, zip-folder) + Server-->>Client: 202 Accepted 응답 (즉시 반환) + Redis->>Worker: Job 전달 + Worker->>Storage: 원본 파일 다운로드 + Worker->>Worker: 로컬 변환/압축 CLI 실행 (PDF/ZIP 생성) + Worker->>Storage: 변환 완료본 업로드 (object_key/preview_key) + Worker-->>Server: Job 완료 알림 + Server-->>Client: Socket.io 실시간 완료 알림 전송 및 새로고침 트리거 +``` + +--- + +## 3. 프로젝트 디렉토리 구조 (Project Structure) +본 프로젝트는 Express MVC 아키텍처 패턴을 기반으로 관심사를 분리하고, 어드민 기능과 외부 라이브러리 연동 모듈을 격리하여 관리하고 있습니다. + +```text +PM_ver4/ +├── app.js # Express 서버 설정, 공통 미들웨어 바인딩, 라우터 매핑 +├── server.js # HTTP 서버 생성, Socket.io 초기화, 서비스 포트 오픈 (기본: 6565) +├── socket.js # Socket.io 실시간 접속자 좌표 및 이벤트 공유 핸들러 +├── queue.js # BullMQ 기반 비동기 작업 대기열 (PDF 변환, AI 요약, ZIP 압축) +├── logger.js # Winston 기반 일별 순환 로그(Winston Daily Rotate File) 설정 +├── config/ # 스토리지 및 인프라 커넥터 설정 +│ ├── cloudClient.js # Cloudflare R2 스토리지 연결 설정 (S3 SDK 기반) +│ ├── onPremiseClient.js # 온프레미스 MinIO 스토리지 연결 설정 +│ └── redis.js # ioredis 기반 Redis 커넥터 +├── db/ # 데이터베이스 액세스 레이어 +│ ├── config.js # ONPREMISE / CLOUD 환경별 DB 설정 분리 +│ ├── pool.js # PostgreSQL pg.Pool 인스턴스 export +│ └── index.js # DB 연결 테스트 및 풀 유효성 검사 +├── routes/ # HTTP REST API 라우팅 정의 +│ ├── mainRouter.js # 호스트 도메인 판별 및 첫 페이지 포워딩 +│ ├── authRouter.js # 세션 및 프로젝트 권한 조회/부여 +│ ├── archiveRouter.js # 파일/폴더 트리, 압축 다운로드, AI 요약 +│ ├── officialDocRouter.js # 수발신 공문 및 Gemini API 연계 API +│ ├── overviewRouter.js # 대시보드 메타데이터, 캘린더, 시설 규모 API +│ ├── commonRouter.js # 환경 설정값 및 공통 메타 조회 API +│ ├── gsimRouter.js # GSIM 연동 API +│ └── admin/ # [격리 배치] 어드민 전용 라우터 (adminRouter.js) +├── controllers/ # 핵심 비즈니스 로직 (Controller) +│ ├── authController.js # 사용자 인증 및 미세 권한(lev) 제어 +│ ├── archiveController.js # 파일 이력/삭제/이동 및 BullMQ 연계 제어 +│ ├── officialDocController.js # 공문서 등록 및 Gemini 구조화 파싱 제어 +│ ├── overviewController.js # 종합 개요 수치 및 일정 관리 +│ ├── commonController.js # 공통 설정 조회/수정 +│ ├── gsimController.js # GSIM 지도 관련 동기화 +│ └── admin/ # [격리 배치] 어드민 전용 비즈니스 로직 (adminController.js) +├── oauth/ # Sentinel SSO 연동 모듈 +│ ├── oauthRouter.js # OAuth 인증 콜백 API +│ ├── oauthController.js # 인증 및 Mock 세션 주입 미들웨어 +│ └── oauthService.js # Sentinel 서버 통신 서비스 +├── programs/ # 백엔드 연동용 로컬 외부 CLI 프로그램 모음 +│ ├── encryp.exe # 보안 파일 암호화 모듈 +│ ├── pdf_thumb.exe # PDF 미리보기 이미지 추출기 +│ ├── dwgToPdfConverter/ # CAD 도면(.dwg, .dxf) PDF 변환 팩 +│ ├── hwpConverter/ # 한글 문서(.hwp) PDF/HTML 변환 팩 +│ └── msofficeConverter/ # MS Office 문서 변환 팩 +├── views/ # 정적 웹 자원 및 클라이언트 템플릿 +│ ├── login/ # 로그인 웹페이지 +│ ├── index/ # 도메인 분기별 첫 마크업 리소스 +│ ├── main/ # 대시보드 및 아카이브 SPA 클라이언트 코드 +│ ├── anti_debugging.js # 소스 스니핑 방지 클라이언트 보안 스크립트 +│ ├── anti-debug-sw.js # 안티 디버깅용 서비스 워커 +│ └── admin/ # [격리 배치] 어드민 웹 대시보드 (html, css, js) +└── package.json # 종속성 구성 및 스크립트 정의 +``` + +--- + +## 4. 관리 데이터 및 시스템 정책 (Managed Data Policy) + +### 4.1 3대 삭제 제한 규칙 (RESTRICT) +시스템 데이터의 정합성과 참조 무결성을 보존하기 위해, 데이터베이스 수준의 물리적 CASCADE 삭제를 금지하고 백엔드 API 단에서 연관 이력을 검사하여 삭제를 제한합니다: +1. **프로젝트 삭제 제한**: 아카이브 파일 데이터(`tb_data`), 공문서 파일(`tb_official_doc_file`), 공지사항(`tb_banner_notice`) 중 해당 프로젝트 ID를 참조하는 레코드가 1건이라도 존재할 경우 삭제 요청을 반려(`400 Bad Request`)합니다. +2. **사용자 삭제 제한**: 프로젝트 권한 매핑 테이블(`tb_permission`)에 해당 유저가 참여 중인 현장 정보가 등록되어 있을 경우 사용자 계정 삭제를 제한합니다. +3. **공통 코드 삭제 제한**: 대분류 마스터 코드(`code_master`) 하위에 소분류 세부 코드(`code_detail`)가 단 1개라도 생성되어 있을 경우 대분류 코드 삭제를 방지합니다. + +### 4.2 시스템 글로벌 보존 정책 및 정기 청소 스케줄러 +시스템의 디스크 용량 관리 및 보안 가이드라인에 따라 자동 파일 삭제 스케줄러가 탑재되어 작동합니다. +* **동작 주기**: 매일 자정 배치 구동 (`node-cron` 또는 백그라운드 스케줄러 엔진) +* **보존 규칙**: `tb_system_policy` 테이블에서 `GLOBAL_DELETE_POLICY` 설정 정보를 로드하여 활성화 여부(`is_active=true`), 보존 기한(`limit_days`), 최대 파일 개수(`limit_file_count`) 기준을 확인합니다. +* **삭제 기법**: 삭제 기준을 충족하는 아카이브 임시 파일들을 MinIO/R2 스토리지에서 제거하고 DB 내 파일 메타데이터 상태를 업데이트합니다. +* **기록 적재**: 작업의 수행 일시, 삭제 경로, 적용된 정책 기준 및 성공 여부를 `tb_auto_clean_log` 테이블에 `SYSTEM` 작업자 식별자로 기록하여 감사 이력을 보존합니다. + +### 4.3 시스템 활동 로그(tb_log) 활동유형 정의 +시스템 내 모든 파일 관리, 사용자 권한 및 프로젝트 설정 관련 중요 감사 로그는 `tb_log` 테이블에 기록되며, `activity` 컬럼에 설정되는 주요 활동유형은 다음과 같습니다. + +#### 1) 아카이브 및 파일 관리 관련 활동유형 (File & Archive Operations) +* **`createFolder`**: 일반 폴더 생성 +* **`createFolder-${folderType}`**: 특정 유형의 폴더 생성 (예: `createFolder-officialDoc` 등) +* **`uploadData_file` / `uploadData_folder`**: 파일 및 폴더 업로드 +* **`uploadData_titleImg`**: 프로젝트 메인 타이틀 이미지 업로드 +* **`renameTarget_file` / `renameTarget_folder`**: 파일/폴더 이름 변경 +* **`editAuthor`**: 파일의 소유자/작성자 정보 수정 +* **`downloadTarget_file` / `downloadTarget_folder`**: 파일 및 폴더 다운로드 +* **`relocateTarget_file` / `relocateTarget_folder`**: 파일 및 폴더 이동 (경로 변경) +* **`removeTarget_file` / `removeTarget_folder`**: 파일 및 폴더 임시 삭제 (휴지통 이동) +* **`removeTarget_folder_expired`**: 보존 정책 만료로 인한 폴더 자동/수동 삭제 (휴지통 이동) +* **`deleteTarget_file` / `deleteTarget_folder`**: 파일 및 폴더 영구 삭제 (오브젝트 스토리지 및 DB 실제 삭제) +* **`setDataPermission_file` / `setDataPermission_folder`**: 파일/폴더 개별 접근 권한 설정 변경 +* **`convertPdf`**: 캐드/오피스 문서의 PDF 뷰어용 변환 처리 기록 +* **`summarizeAI`**: Google Gemini AI를 활용한 공문서 요약 처리 기록 + +#### 2) 프로젝트 내 사용자 권한 관련 활동유형 (Project Member Permissions) +* **`addPermission_subMaster` / `deletePermission_subMaster`**: 프로젝트 서브 마스터 권한 부여 및 삭제 +* **`addPermission_securityWorker` / `deletePermission_securityWorker`**: 프로젝트 보안 작업자 권한 부여 및 삭제 +* **`addPermission_${lev}` / `deletePermission_${lev}`**: 기타 권한 부여 및 삭제 (예: `worker`, `viewer` 등) + +#### 3) 시스템 및 프로젝트 관리자 활동유형 (System Admin Audit Logs) +* **`createProject` / `updateProject` / `deleteProject`**: 프로젝트 등록, 수정 및 삭제 +* **`assignPermission` / `updatePermission` / `removePermission`**: 사용자에게 프로젝트 권한 최초 할당, 수정 및 삭제 +* **`createBanner` / `stopBanner`**: 시스템 공지사항 배너 등록 및 노출 중단 +* **`createUser` / `updateUser` / `deleteUser`**: 시스템 계정(관리자 포함) 생성, 정보 수정(비밀번호 초기화 등) 및 삭제 +* **`updateSystemPolicy`**: 시스템 보관/삭제 및 정기 자동 청소(Auto-Clean) 정책 정보 변경 +* **`createCodeMaster` / `updateCodeMaster` / `deleteCodeMaster`**: 공통 대분류 코드 등록, 수정 및 삭제 +* **`createCodeDetail` / `updateCodeDetail` / `deleteCodeDetail`**: 공통 소분류 코드 등록, 수정 및 삭제 + +--- + + +## 5. 코드 기준 및 개발 표준 (Code Standards) +프로젝트 코드의 가독성과 확장성을 일관되게 유지하기 위해 다음 기준을 반드시 준수해야 합니다. + +* **엄격한 모듈 격리 배치**: + * 새로 추가되는 관리자(Admin) 관련 기능은 기존 어플리케이션 소스 영역에 결합하지 않고, `routes/admin/`, `controllers/admin/`, `views/admin/` 하위에 완전히 격리 배치하여 개발합니다. +* **비동기 예외 세이프가드 (Crash Safeguard)**: + * 비정상적인 DB 쿼리 에러나 `undefined` 참조 오류가 발생했을 때 Node.js 프로세스가 다운되는 것을 막기 위해 모든 컨트롤러 함수 및 미들웨어의 내부 로직은 `try-catch` 블록으로 래핑하고 적절한 에러 로깅을 수행해야 합니다. +* **데이터베이스 연결 풀(Pool) 관리**: + * 데이터베이스의 불필요한 커넥션 점유를 막고 자원을 효율화하기 위해 `db/config.js`에는 `idleTimeoutMillis: 5000` 및 `connectionTimeoutMillis: 5000`으로 타이트한 타임아웃 규칙이 적용되어 있습니다. 쿼리 실행 후에는 반드시 `client.release()`를 호출하여 커넥션을 풀로 즉시 반환하십시오. +* **원격 오류 수집 디버거**: + * 클라이언트 측 브라우저에서 스크립트 실행 오류 발생 시 `/api/common/log-client-error` 엔드포인트를 통해 에러 스택을 실시간 수집하고 있으므로, 배포 전 개발 모드 상태에서 콘솔의 `🚨 [CLIENT ERROR]` 모니터링을 상시 수행하십시오. + +--- + +## 6. 사용 권한 체계 (User Permission System) +유저의 세션 그룹 권한과 개별 프로젝트별 참여 권한 등급(`lev`)은 다음과 같이 비트/상수 형식으로 구분 및 통제됩니다. + +| 권한 레벨 (`lev`) | 그룹 / 명칭 | 시스템 상의 주요 권한 및 설명 | +| :---: | :--- | :--- | +| **255** | **Owner / Super** | 프로젝트의 소유주 및 시스템 최고 관리자. 프로젝트 삭제, 유저 전체 권한 제어, 어드민 모달 접근 및 비활성 현장 강제 접근 우회 가능. | +| **191** | **Master** | 프로젝트 실질 관리자. 현장 내 모든 폴더/파일 관리, 권한 설정 변경, Zip 일괄 다운로드 가능. | +| **127** | **Sub-Master** | 부관리자. 폴더 생성/삭제, 과업개요 정보 수정, 하위 사용자 권한 배정 및 해제 가능. | +| **15** | **Security Worker** | 보안 담당 참여자. 보안으로 지정된 폴더 및 파일에 대한 읽기/쓰기 권한 접근 가능. | +| **7** | **Worker** | 일반 협업 실무자. 일반 폴더에 대한 파일 업로드/다운로드, 메모 생성 및 Gemini AI 문서 요약 기능 사용 가능. | +| **1** | **Viewer** | 단순 모니터링 참관자. 파일의 조회가 가능하지만, 데이터 수정/업로드/삭제 버튼 등 일체의 제어용 DOM 요소가 화면에서 자동 제거됩니다. | + +### 6.1 폴더별 접근 권한 설정 (Folder-Level Permissions) +최상단 1~3단계 폴더(`data_depth <= 3`)에 대해 프로젝트 참여자의 접근 등급을 개별 설정하여 미세 제어할 수 있습니다. 4단계 이하 하위 폴더와 파일은 상위 3단계 부모 폴더의 권한 설정을 실시간으로 동적 자동 상속받습니다. + +#### 1) 폴더별 사용자 권한 등급 +* **접근 차단 (None - `0`)**: 해당 폴더 및 하위 파일 전체에 대해 조회 및 접근이 차단됩니다. (화면에 노출되지 않음) +* **참관자 (Viewer - `1`)**: 해당 폴더 및 하위 파일에 대해 읽기(조회/다운로드)만 가능합니다. +* **일반참여자 (Worker - `7`)**: 해당 폴더 및 하위 파일에 대해 업로드, 수정, 삭제가 가능합니다. +* **부관리자 (Sub-Master - `191`)**: 폴더 권한 설정을 변경할 수 있습니다. + +#### 2) 권한 동적 상속 및 탐색 규칙 +* 1~3단계 폴더는 `tb_folder_permission` 테이블에 개별 저장됩니다. +* 4단계 이하(`data_depth >= 4`) 데이터는 자체 권한값을 저장하지 않고, 쿼리 실행 시 `path1`, `path2`, `path3` 경로와 조인하여 상위 3단계 부모 폴더의 권한을 동적으로 탐색하여 상속받습니다. + +--- + +## 7. 핵심 기능 및 주요 시나리오 (Core Scenarios) + +### 7.1 Presigned URL 기반 대용량 파일 업로드 +도면 등 대용량 파일 전송 시 백엔드 WAS의 입출력 부하 및 메모리 병목 현상을 방지하기 위해 Presigned URL 통신 기법을 도입하였습니다. +1. **URL 요청**: 브라우저에서 파일을 선택하면 백엔드 API(`GET /api/archive/presigned-url`)에 업로드 대상 정보를 전송합니다. +2. **보안 서명 발급**: 서버는 Cloudflare R2 / MinIO SDK를 사용하여 특정 버킷과 키에 대한 보안 업로드 서명이 담긴 임시 URL을 클라이언트에 발급합니다. +3. **직접 업로드**: 브라우저가 Axios를 이용해 스토리지(MinIO/R2) 엔드포인트로 파일을 직접 `PUT` 전송하여 파일 업로드를 완료합니다. +4. **메타 적재**: 업로드 성공 후 백엔드 서버에 성공 패킷을 전송하여 `tb_data` 테이블에 최종 트리 정보를 등록합니다. + +### 7.2 오피스 및 CAD 도면 자동 PDF 변환 뷰어 +다양한 산출물 포맷(.hwp, .dwg, .docx 등)을 웹상에서 별도 프로그램 설치 없이 즉시 조회할 수 있는 통합 문서 뷰어 파이프라인을 지원합니다. +1. **변환 작업 큐잉**: 파일 업로드 감지 시 백엔드 큐(`convert-pdf`)에 변환 태스크가 추가됩니다. +2. **백그라운드 CLI 구동**: 비동기 워커가 `programs/` 내의 `dwgToPdfConverter`, `hwpConverter` 등 변환 CLI 프로세스를 작동시켜 PDF 표준 문서로 자동 렌더링을 진행합니다. +3. **캐싱 및 썸네일**: 변환된 PDF는 스토리지 내 `preview_key` 경로에 적재되고, 동시에 1페이지를 썸네일 이미지(`thumbnail_key`)로 추출하여 그리드 뷰에 제공합니다. + +### 7.3 Google Gemini AI 기반 공문서 지능형 파싱 (Structured Output) +수발신 공문 처리 시 요약 및 정보 입력을 자동화하기 위해 AI 구조화 데이터 추출 기능이 탑재되어 있습니다. +1. **멀티 파트 수집**: 업로드된 문서 파일(`input_file`), 추출용 지시 가이드라인 프롬프트(`prompt_file`), 출력 형태를 고정한 JSON 스키마 규격(`schema_file`)을 수집합니다. +2. **Gemini API 연동**: Gemini 2.5 Flash API에 수집된 파일 세트를 전달하고 `responseSchema`를 강제 지정합니다. +3. **구조화 정보 저장**: Gemini가 반환한 완벽한 JSON 포맷 데이터(수/발신처, 공문 번호, 개조식 제목 요약, 관련 문서 ID)를 검증 후 `tb_official_doc_file` 컬럼에 자동 매핑하여 적재합니다. + +--- + +## 8. 실 가동 환경 세팅 및 프로덕션 설정 (Production Setup) + +실서버(운영 및 스테이징) 배포 시 시스템이 안정적으로 구동되기 위해 구성되어야 하는 인프라 설정 스펙입니다. + +### 8.1 서버 운영체제(OS) 사양 필수 조치 +* **권장 OS**: **Windows Server 2019 / 2022** 이상 +* **필수 이유**: `programs/` 폴더 내부에서 동작하는 한글 문서 변환(`hwpConverter`), CAD 도면 변환(`dwgToPdfConverter`), 문서 미리보기 이미지 추출(`pdf_thumb.exe`) 등 핵심 문서 변환 엔진들이 **Windows Native 32/64비트 실행 파일(.exe CLI)** 및 동적 연결 라이브러리(DLL)에 전적으로 의존하고 있습니다. 따라서 실서버 환경은 반드시 Windows Server OS 상에서 Node.js 프로세스가 직접 실행되어야 합니다. + +### 8.2 프로덕션 환경변수(`.env`) 구성 표준 +가동 환경에 맞춰 `.env` 내의 파라미터를 다음과 같이 엄격하게 구분하여 배포합니다. + +```env +# 1. 런타임 최적화 모드 및 고유 식별자 설정 +NODE_ENV=production +SERVICE_NAME=PM_PRODUCTION # 다중 도메인 매핑 시 호스트 판별 기준자 +DEPLOYMENT_TYPE=CLOUD # CLOUD(R2 스토리지) 또는 ONPREMISE(MinIO) 분기 + +# 2. 포트 및 리스닝 IP +LOCAL_IP=0.0.0.0 # 외부 트래픽 수신 허용 +LOCAL_PORT=6565 + +# 3. 운영 PostgreSQL 데이터베이스 설정 +ONPREMISE_POSTGRES_HOST=10.0.10.5 # 격리된 내부망 DB 서버 IP +ONPREMISE_POSTGRES_PORT=5432 +ONPREMISE_POSTGRES_DATABASE=pm_prod_db +ONPREMISE_POSTGRES_USER=pm_db_admin +ONPREMISE_POSTGRES_PASSWORD=secure_prod_password + +# 4. Redis 고가용성 세션/큐 스토어 +REDIS_HOST=10.0.10.6 +REDIS_PORT=6379 +REDIS_PASSWORD=secure_redis_password + +# 5. Cloudflare R2 스토리지 자격 증명 (S3 호환 엔드포인트) +MINIO_ENDPOINT=https://your-cf-account-id.r2.cloudflarestorage.com +MINIO_ACCESSKEYID=your_r2_access_key +MINIO_SECRETACCESSKEY=your_r2_secret_key +``` + +### 8.3 Nginx 역방향 프록시(Reverse Proxy) 및 SSL 종단 구성 +외부망 포트 `80(HTTP)` 및 `443(HTTPS)` 서비스 노출을 위해 웹 서버 전면에 Nginx를 역방향 프록시로 배치하고 SSL 인증서 처리를 종단합니다. +* **Nginx 필수 웹소켓 프록시 설정**: 실시간 마우스 커서 트래킹(`socket.io`)이 끊기지 않도록 `Upgrade` 및 `Connection` 헤더를 백엔드로 넘겨주어야 합니다. +```nginx +server { + listen 443 ssl; + server_name bim.yourdomain.co.kr; + + ssl_certificate c:/nginx/conf/ssl/yourdomain.crt; + ssl_certificate_key c:/nginx/conf/ssl/yourdomain.key; + + location / { + proxy_pass http://127.0.0.1:6565; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_cache_bypass $http_upgrade; + } +} +``` + +### 8.4 PM2 프로세스 매니저 기반의 무중단 기동 +Windows 환경에서 백엔드 Node.js 프로세스가 장애 시 자동 재기동되고 CPU 멀티 코어를 모두 활용하도록 **PM2 클러스터 모드**를 연동합니다. +1. PM2를 윈도우 백그라운드 서비스로 등록합니다 (`pm2-windows-service` 모듈 연동). +2. 아래 명령어를 통해 멀티 코어 클러스터 인스턴스를 무중단 기동합니다: + ```bash + pm2 start server.js -i max --name "pm-server-prod" + ``` + +--- + +## 9. 개발자 준비 사항 (Developer Prerequisites) + +시스템을 인계받거나 신규 배포 프로세스를 주도하는 개발자가 가동 전에 반드시 숙지하고 확보해야 하는 자격 증명 및 준비 사항입니다. + +* **OAuth (Sentinel SSO) 연동 정보 확보**: + * 실 서버 배포 시 백엔드가 사내 SSO 인증 공급자와 토큰 정보를 통신할 수 있도록 사내 네트워크 방화벽 허용 및 SSO 클라이언트 키(`CLIENT_ID`, `CLIENT_SECRET`)를 OAuth 관리 담당 부서로부터 발급받아야 합니다. +* **Google Gemini API 실서버용 인증 키 발급**: + * 문서 자동 요약 및 인텔리전트 공문 파싱 한도 초과 오류(Rate Limit) 방지를 위해 결제가 연동된 Google AI Studio의 프로덕션 전용 API Key를 발급받아 `.env`에 설정해야 합니다. +* **안티 디버깅 코드 원복 검증**: + * 개발 모드 분석을 위해 프론트엔드의 `anti_debugging.js` 로드 미들웨어나 임포트 구문을 변경/주석 처리했다면, 배포용 빌드 완료 전 반드시 원복하여 운영 환경에서 스니핑 보안이 즉각 작동되도록 조치해야 합니다. +* **R2 / MinIO Bucket 정책 제어 권한**: + * 파일이 무단으로 유출되는 것을 막기 위해 스토리지 버킷의 public 접근 권한을 'Private'으로 격리하고, 오직 백엔드 서버만이 Presigned URL을 발행하고 파일을 지울 수 있는 S3 IAM 정책을 설계 및 연결해야 합니다. + +--- + +## 10. 유지보수 가이드 (Maintenance Guide) + +실서버 구동 후 발생할 수 있는 이상 현상 조치 및 일상적 점검 가이드라인입니다. + +### 10.1 BullMQ 비동기 작업 큐 모니터링 (Bull Board) +PDF 변환 파일의 락 현상이나 MinIO 스토리지 전송 지연 등으로 비동기 변환/압축 작업이 실패할 경우 큐가 정체될 수 있습니다. +* **모니터링 대시보드 접속**: `http://[운영서버IP]:6565/admin/queues` +* **조치 요령**: 어드민 세션(`USER_GROUP_super`)으로 접속 후 **Bull Board 웹 UI** 상에서 실패한 작업(`Failed`)을 검사하고, 오류 원인(Stack Trace) 분석 후 **Retry** 버튼을 클릭하여 작업을 재실행하거나 쓰레기 작업을 **Clean** 합니다. + +### 10.2 Winston 일별 순환 로그 및 예외 감시 +`logger.js`에 설정된 Winston Daily Rotate File 라이브러리가 로그를 일별 분할하여 백업합니다. +* **로그 디렉토리**: `trunk/PM_ver4/logs/` +* **주요 점검 파일**: + * `yyyy-MM-dd.error.log` (서버 비즈니스 예외) + * `yyyy-MM-dd.exception.log` (예기치 못한 Uncaught Exception) +* **조치**: 배치 장애나 DB 연결 이슈가 발생하는지 최소 주 1회 로그 상태를 확인하고 디스크가 고갈되지 않도록 보존 기한을 관리합니다. + +### 10.3 DB 커넥션 고갈 방지 및 모니터링 +다중 접속 상황에서 웹서버 연결이 지연되고 `ETIMEDOUT` 오류가 떨어지는 경우 DB 풀 고갈을 의심해야 합니다. +* **원인**: `client.release()` 구문이 누락되거나 오류 발생 후 커넥션이 회수되지 않은 경우. +* **대응**: `pool.js` 설정 파일의 `max: 20` 값을 모니터링 툴(Grafana / pg_activity)의 액티브 쿼리 수치를 기반으로 트래픽에 대응하여 점진적으로 상향 조치합니다. + +### 10.4 자동 보존 정리 배치 실행 로그 감시 +자정마다 돌며 스토리지를 정리해 주는 정리 배치가 누출 없이 작동 중인지 `tb_auto_clean_log` 테이블을 상시 쿼리하여 점검합니다. +```sql +SELECT clean_date, clean_path, criteria_info, result_status +FROM ver4.tb_auto_clean_log +ORDER BY clean_date DESC +LIMIT 10; +``` +* `result_status`가 `FAIL`로 찍히는 경우, 백엔드 서버에서 R2/MinIO로의 S3 API 네트워크 접속 유실이 일어났거나 IAM Secret Key 만료 문제가 발생한 것이므로 환경변수 자격 증명을 갱신해 주어야 합니다. diff --git a/auto_start_pm.bat b/auto_start_pm.bat new file mode 100644 index 0000000..59842ae --- /dev/null +++ b/auto_start_pm.bat @@ -0,0 +1,21 @@ +@echo off +chcp 65001 > nul +title PROJECT MASTER ver 4.0 Auto Starter + +echo ============================================== +echo 1. Starting WSL2 Keep-Alive session... +echo ============================================== +start /b wsl -d Ubuntu bash -c "sleep infinity" + +echo. +echo ============================================== +echo 2. Starting Docker Compose in WSL2 Ubuntu... +echo ============================================== +wsl -d Ubuntu bash -c "cd '/mnt/d/40. 개발소스/04. PM/pm_ver4/trunk/PM_ver4' && docker compose up -d" + +echo. +echo ============================================== +echo 3. Starting PM ver4 Node.js Web Server... +echo ============================================== +cd /d "D:\40. 개발소스\04. PM\pm_ver4\trunk\PM_ver4" +npm run start diff --git a/controllers/admin/adminController.js b/controllers/admin/adminController.js index 6588328..f609f55 100644 --- a/controllers/admin/adminController.js +++ b/controllers/admin/adminController.js @@ -1,11 +1,18 @@ +/** + * [변경 이력 (Auto-Generated by AI)] + * - 수정일시: 2026-06-15 11:40:00 + * - 수정원인: 폴더별 권한 관리 트리 노드의 data_permission 기준 정렬 요구사항 반영 + * - 수정내용: getFolderPermissions API의 foldersQuery에 data_permission 컬럼을 조회하도록 SELECT절 추가 + */ const pool = require("../../db/pool.js"); const crypto = require("crypto"); const env = process.env.NODE_ENV; const tbProject = env === 'production' ? 'tb_project' : '_test_tb_project'; const tbData = env === 'production' ? 'tb_data' : '_test_tb_data'; -const tbLog = 'tb_log'; +const tbLog = env === 'production' ? 'tb_log' : '_test_tb_log'; const tbPermission = env === 'production' ? 'tb_permission' : '_test_tb_permission'; +const tbFolderPermission = env === 'production' ? 'tb_folder_permission' : '_test_tb_folder_permission'; // 감사 로그(Audit Log) 삽입 헬퍼 함수 (메인 트랜잭션에 영향을 주지 않기 위해 pool.query 사용) @@ -578,34 +585,39 @@ exports.deleteUser = async (req, res) => { } }; -// 5. 감사 로그 조회 (Audit Logs) +// 5. 활동 로그 조회 (Activity Logs) exports.getAuditLogs = async (req, res) => { - const { user_id, activity } = req.query; + const { user_id, activity, project_nm } = req.query; const client = await pool.connect(); try { let query = ` - SELECT log_id, log_date as clean_date, project_id, user_id, user_ip, activity as clean_path, path_arr as criteria_info - FROM ver4.${tbLog} + 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 + LEFT JOIN ver4.${tbProject} p ON l.project_id = p.project_id WHERE 1=1 `; const params = []; let paramIndex = 1; if (user_id) { - query += ` AND user_id ILIKE $${paramIndex++}`; + query += ` AND l.user_id ILIKE $${paramIndex++}`; params.push(`%${user_id}%`); } + if (project_nm) { + query += ` AND (p.project_nm ILIKE $${paramIndex++} OR l.project_id ILIKE $${paramIndex - 1})`; + params.push(`%${project_nm}%`); + } if (activity && activity !== 'all') { - query += ` AND activity = $${paramIndex++}`; - params.push(activity); + query += ` AND l.activity ILIKE $${paramIndex++}`; + params.push(`%${activity}%`); } - query += ` ORDER BY log_id DESC LIMIT 100;`; + query += ` ORDER BY l.log_id DESC LIMIT 100;`; const result = await client.query(query, params); res.status(200).json(result.rows); } catch (err) { console.error("getAuditLogs Error:", err); - res.status(500).json({ error: "감사 로그 조회 실패" }); + res.status(500).json({ error: "활동 로그 조회 실패" }); } finally { client.release(); } @@ -902,3 +914,118 @@ exports.deleteCodeDetail = async (req, res) => { client.release(); } }; + +// 2-1. Folder-Level Permissions +exports.getFolderPermissions = async (req, res) => { + const { projectId } = req.params; + const client = await pool.connect(); + try { + // 1. Fetch folders (depth <= 3) + const foldersQuery = ` + SELECT data_id, path1, path2, path3, data_depth, is_folder, data_permission + FROM ver4.${tbData} + WHERE project_id = $1 AND is_folder = true AND is_removed = false AND data_depth <= 3 + ORDER BY path1, path2, path3; + `; + const foldersRes = await client.query(foldersQuery, [projectId]); + + // 2. Fetch current folder permissions + const folderPermsQuery = ` + SELECT fp.folder_permission_id, fp.folder_path_key, fp.user_id, fp.lev, u.user_nm + FROM ver4.${tbFolderPermission} fp + JOIN ver4.tb_user u ON fp.user_id = u.user_id + WHERE fp.project_id = $1; + `; + const folderPermsRes = await client.query(folderPermsQuery, [projectId]); + + // 3. Fetch project users + const usersQuery = ` + SELECT pm.user_id, u.user_nm, u.company, u.dept, u.position, pm.lev as project_lev + FROM ver4.${tbPermission} pm + JOIN ver4.tb_user u ON pm.user_id = u.user_id + WHERE pm.project_id = $1 + ORDER BY u.user_nm ASC; + `; + const usersRes = await client.query(usersQuery, [projectId]); + + res.status(200).json({ + folders: foldersRes.rows, + folderPermissions: folderPermsRes.rows, + users: usersRes.rows + }); + } catch (err) { + console.error("getFolderPermissions Error:", err); + res.status(500).json({ error: "폴더 권한 정보 조회 실패" }); + } finally { + client.release(); + } +}; + +exports.assignFolderPermissions = async (req, res) => { + const { project_id, folder_path_key, user_id, lev } = req.body; + if (!project_id || !folder_path_key || !user_id || lev === undefined) { + return res.status(400).json({ error: "필수 파라미터가 누락되었습니다." }); + } + + const client = await pool.connect(); + try { + const query = ` + INSERT INTO ver4.${tbFolderPermission} (project_id, folder_path_key, user_id, lev, mod_date) + VALUES ($1, $2, $3, $4, CURRENT_TIMESTAMP) + ON CONFLICT (project_id, folder_path_key, user_id) + DO UPDATE SET lev = EXCLUDED.lev, mod_date = CURRENT_TIMESTAMP + RETURNING *; + `; + const result = await client.query(query, [project_id, folder_path_key, user_id, lev]); + + const userIp = req.headers['cf-connecting-ip'] || req.ip || req.headers['x-forwarded-for'] || req.connection?.remoteAddress; + await insertAuditLog(project_id, 'assignFolderPermission', req.user?.user_id, userIp, [ + `Folder path: ${folder_path_key}`, + `Assigned user_id: ${user_id}`, + `Folder level assigned: ${lev}` + ]); + + res.status(200).json({ + message: "폴더 권한이 성공적으로 부여되었습니다.", + data: result.rows[0] + }); + } catch (err) { + console.error("assignFolderPermissions Error:", err); + res.status(500).json({ error: "폴더 권한 부여 실패" }); + } finally { + client.release(); + } +}; + +exports.removeFolderPermission = async (req, res) => { + const { project_id, folder_path_key, user_id } = req.body; + if (!project_id || !folder_path_key || !user_id) { + return res.status(400).json({ error: "필수 파라미터가 누락되었습니다." }); + } + + const client = await pool.connect(); + try { + const query = ` + DELETE FROM ver4.${tbFolderPermission} + WHERE project_id = $1 AND folder_path_key = $2 AND user_id = $3 + RETURNING *; + `; + const result = await client.query(query, [project_id, folder_path_key, user_id]); + if (result.rows.length === 0) { + return res.status(404).json({ error: "대상을 찾을 수 없습니다." }); + } + + const userIp = req.headers['cf-connecting-ip'] || req.ip || req.headers['x-forwarded-for'] || req.connection?.remoteAddress; + await insertAuditLog(project_id, 'removeFolderPermission', req.user?.user_id, userIp, [ + `Folder path: ${folder_path_key}`, + `Removed user_id: ${user_id}` + ]); + + res.status(200).json({ message: "폴더 권한이 제거되었으며 상속 상태로 초기화되었습니다." }); + } catch (err) { + console.error("removeFolderPermission Error:", err); + res.status(500).json({ error: "폴더 권한 삭제 실패" }); + } finally { + client.release(); + } +}; diff --git a/controllers/archiveController.js b/controllers/archiveController.js index 305caa1..fea4796 100644 --- a/controllers/archiveController.js +++ b/controllers/archiveController.js @@ -43,11 +43,12 @@ const cloudType = process.env.CLOUD_TYPE; //// env의 NODE_ENV에 따라 DB 테이블 이름 설정 const env = process.env.NODE_ENV; -const tbLog = 'tb_log'; +const tbLog = env === 'production' ? 'tb_log' : '_test_tb_log'; const tbData = env == 'production'? 'tb_data':'_test_tb_data'; const tbClickLog = env == 'production'? 'tb_click_log':'_test_tb_click_log'; const tbProject = env === 'production' ? 'tb_project' : '_test_tb_project'; const tbPermission = env === 'production' ? 'tb_permission' : '_test_tb_permission'; +const tbFolderPermission = env === 'production' ? 'tb_folder_permission' : '_test_tb_folder_permission'; // 테스트 // const tbLog = env == 'production'? 'tb_log':'tb_log'; @@ -281,16 +282,23 @@ async function selectData(projectId, storageType, userInfo, resourcePath) { } queryString += ` - AND ((d.data_permission+32)&`; - if (permission) { - queryString += `$${paramCounter++}`; - values.push(permission); - } else { - queryString += `(SELECT lev FROM ver4.${tbPermission} WHERE project_id = $${paramCounter++} AND user_id = $${paramCounter++})`; - values.push(projectId); - values.push(userId); - } - queryString += `) <> 0`; + AND ((d.data_permission+32) & COALESCE( + (SELECT lev FROM ver4.${tbFolderPermission} + WHERE project_id = d.project_id + AND user_id = $${paramCounter++} + AND folder_path_key = CASE + WHEN d.data_depth = 1 THEN d.path1 + WHEN d.data_depth = 2 THEN CONCAT(d.path1, '/', d.path2) + ELSE CONCAT(d.path1, '/', d.path2, '/', d.path3) + END), + (SELECT lev FROM ver4.${tbPermission} + WHERE project_id = d.project_id + AND user_id = $${paramCounter++}), + $${paramCounter++}::integer + )) <> 0`; + values.push(userId); + values.push(userId); + values.push(parseInt(permission) || 0); queryString += ` ORDER BY path1, path2, path3, path4, path5, path6, path7, path8;`; @@ -425,16 +433,23 @@ async function selectRemovedData(projectId, storageType, userInfo) { values.push(storageType); queryString += ` - AND ((d.data_permission+32)&`; - if (permission) { - queryString += `$${paramCounter++}`; - values.push(permission); - } else { - queryString += `(SELECT lev FROM ver4.${tbPermission} WHERE project_id = $${paramCounter++} AND user_id = $${paramCounter++})`; - values.push(projectId); - values.push(userId); - } - queryString += `) <> 0`; + AND ((d.data_permission+32) & COALESCE( + (SELECT lev FROM ver4.${tbFolderPermission} + WHERE project_id = d.project_id + AND user_id = $${paramCounter++} + AND folder_path_key = CASE + WHEN d.data_depth = 1 THEN d.path1 + WHEN d.data_depth = 2 THEN CONCAT(d.path1, '/', d.path2) + ELSE CONCAT(d.path1, '/', d.path2, '/', d.path3) + END), + (SELECT lev FROM ver4.${tbPermission} + WHERE project_id = d.project_id + AND user_id = $${paramCounter++}), + $${paramCounter++}::integer + )) <> 0`; + values.push(userId); + values.push(userId); + values.push(parseInt(permission) || 0); queryString += ` ORDER BY path1, path2, path3, path4, path5, path6, path7, path8;`; @@ -773,6 +788,7 @@ async function insertData(params) { return result; } catch(error) { console.error("insertData err:", error); + return { message: 'insertData_failed', error: error }; } finally { client.release(); } @@ -848,6 +864,7 @@ async function insertLog(params, from) { return result; } catch(error) { console.error("insertLog err:", error); + return { message: 'insertLog_failed', error: error }; } finally { client.release(); } @@ -2328,42 +2345,54 @@ exports.checkTargetExists = async (req, res) => { } exports.createFolder = async (req, res) => { - const projectId = req.baseUrl.split('/')[1]; + try { + const projectId = req.baseUrl.split('/')[1]; - let { params } = req.body; - params.projectId = projectId; + let { params } = req.body; + params.projectId = projectId; - let activity = 'createFolder'; - let folderType = params.folderType; - if (folderType) activity = `${activity}-${folderType}`; - params.activity = activity; + let activity = 'createFolder'; + let folderType = params.folderType; + if (folderType) activity = `${activity}-${folderType}`; + params.activity = activity; - let insertDataResult = await insertData(params); - if (insertDataResult.message == 'insertData_success') { - let dataIdArr = []; - for (let i = 0; i < insertDataResult.rows.length; i++) { - let row = insertDataResult.rows[i]; - dataIdArr.push(row.data_id); - } - params.dataIdArr = dataIdArr; - params.userIp = req.ip; - - let insertLogResult = await insertLog(params); - if (insertLogResult.message == 'insertLog_success') { - let resultData = { - message: 'createFolder_success', - projectId: projectId, - activity: activity, - resourcePath: params.resourcePathArr[0] - }; - - let io = getIo(); - io.emit('createFolder_success', resultData); - - res.status(200).json({ - message: 'createFolder_success', - }); + let insertDataResult = await insertData(params); + if (insertDataResult && insertDataResult.message == 'insertData_success') { + let dataIdArr = []; + for (let i = 0; i < insertDataResult.rows.length; i++) { + let row = insertDataResult.rows[i]; + dataIdArr.push(row.data_id); + } + params.dataIdArr = dataIdArr; + params.userIp = req.ip; + + let insertLogResult = await insertLog(params); + if (insertLogResult && insertLogResult.message == 'insertLog_success') { + let resultData = { + message: 'createFolder_success', + projectId: projectId, + activity: activity, + resourcePath: params.resourcePathArr[0] + }; + + let io = getIo(); + io.emit('createFolder_success', resultData); + + return res.status(200).json({ + message: 'createFolder_success', + }); + } } + res.status(500).json({ + message: 'createFolder_failed', + error: '폴더 생성 중 오류가 발생했습니다.' + }); + } catch (error) { + console.error("createFolder error:", error); + res.status(500).json({ + message: 'createFolder_failed', + error: error.message + }); } } @@ -2847,55 +2876,62 @@ exports.relocateTarget = async(req, res) => { } exports.removeTarget = async(req, res) => { - let { params } = req.body; - let permission = JSON.parse(params.userInfoString).permission; - let depth = getDepth(params.resourcePathArr[0]); - let isRecycleBinModal = params.isRecycleBinModal; + try { + let { params } = req.body; + let permission = JSON.parse(params.userInfoString).permission; + let depth = getDepth(params.resourcePathArr[0]); + let isRecycleBinModal = params.isRecycleBinModal; - if (!isRecycleBinModal && (depth == 1 && permission < 191) || (depth >= 2 && permission < 7)) { - res.status(200).json({ - message: 'removeTarget_failed_permission', - }); - } else { - const projectId = req.baseUrl.split('/')[1]; - params.projectId = projectId; + if (!isRecycleBinModal && (depth == 1 && permission < 191) || (depth >= 2 && permission < 7)) { + return res.status(200).json({ + message: 'removeTarget_failed_permission', + }); + } else { + const projectId = req.baseUrl.split('/')[1]; + params.projectId = projectId; - // console.log('!!!!!!!!!!!!!!!!!!!!!! removeTarget'); - // console.log(params); + let activity = `removeTarget_${params.dataType}`; + params.activity = activity; - let activity = `removeTarget_${params.dataType}`; - params.activity = activity; + let updateDataRemoveResult = await updateDataRemove(params); + if (updateDataRemoveResult && updateDataRemoveResult.message == 'updateDataRemove_success') { + params.userIp = req.ip; - let updateDataRemoveResult = await updateDataRemove(params); - if (updateDataRemoveResult.message == 'updateDataRemove_success') { - params.userIp = req.ip; + if (params.dataType == 'file') { + let updateLastFolderActDateResult = await updateLastFolderActDate(params.depth3DataIdArr); + } - if (params.dataType == 'file') { - let updateLastFolderActDateResult = await updateLastFolderActDate(params.depth3DataIdArr); - // if (updateLastFolderActDateResult.message == 'updateLastFolderActDate_success') { - // } - } - - let insertLogResult = await insertLog(params); - if (insertLogResult.message == 'insertLog_success') { - - let resultData = { - message: `removeTarget_success`, - projectId: projectId, - activity: activity, - resourcePathArr: params.resourcePathArr, - userInfoString: params.userInfoString, - isExpiredFolder: params.isExpiredFolder - }; - - let io = getIo(); - io.emit('removeTarget_success', resultData); - - res.status(200).json({ - message: 'removeTarget_success', - }); + let insertLogResult = await insertLog(params); + if (insertLogResult && insertLogResult.message == 'insertLog_success') { + + let resultData = { + message: `removeTarget_success`, + projectId: projectId, + activity: activity, + resourcePathArr: params.resourcePathArr, + userInfoString: params.userInfoString, + isExpiredFolder: params.isExpiredFolder + }; + + let io = getIo(); + io.emit('removeTarget_success', resultData); + + return res.status(200).json({ + message: 'removeTarget_success', + }); + } } + res.status(500).json({ + message: 'removeTarget_failed', + error: '대상 제거 중 오류가 발생했습니다.' + }); } + } catch (error) { + console.error("removeTarget error:", error); + res.status(500).json({ + message: 'removeTarget_failed', + error: error.message + }); } } diff --git a/install_docker_wsl.sh b/install_docker_wsl.sh new file mode 100644 index 0000000..eacc356 --- /dev/null +++ b/install_docker_wsl.sh @@ -0,0 +1,55 @@ +#!/bin/bash +# install_docker_wsl.sh +# Script to install Docker Engine on WSL2 Ubuntu (24.04 Noble) + +set -e + +echo "=== Starting Docker Engine Installation on WSL2 Ubuntu ===" + +# 1. Update package index +echo "Updating package index..." +sudo apt-get update -y + +# 2. Install prerequisites +echo "Installing prerequisites..." +sudo apt-get install -y ca-certificates curl gnupg + +# 3. Add Docker GPG Key +echo "Adding Docker's official GPG key..." +sudo install -m 0755 -d /etc/apt/keyrings +sudo curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc +sudo chmod a+r /etc/apt/keyrings/docker.asc + +# 4. Add Docker repository to Apt sources +echo "Adding Docker repository to Apt sources..." +echo \ + "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.download.com/linux/ubuntu \ + $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | \ + sudo tee /etc/apt/sources.list.d/docker.list > /dev/null + +# Fix the domain to official docker +sudo sed -i 's/download.download.com/download.docker.com/g' /etc/apt/sources.list.d/docker.list + +# 5. Update package index again +echo "Updating package index with Docker repository..." +sudo apt-get update -y + +# 6. Install Docker packages +echo "Installing Docker Engine, CLI, Containerd, and Compose..." +sudo apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin + +# 7. Start and enable Docker service (via systemd) +echo "Starting and enabling Docker service..." +sudo systemctl enable docker +sudo systemctl start docker + +# 8. Verification +echo "Verifying installation..." +docker --version +docker compose version + +# 9. Test run +echo "Running hello-world container..." +sudo docker run --rm hello-world + +echo "=== Docker Engine Installation Completed Successfully ===" diff --git a/logs/.b654e5b245003e35c7a21bd1b2e78bed5f148798-audit.json b/logs/.b654e5b245003e35c7a21bd1b2e78bed5f148798-audit.json index 3963f93..8b81878 100644 --- a/logs/.b654e5b245003e35c7a21bd1b2e78bed5f148798-audit.json +++ b/logs/.b654e5b245003e35c7a21bd1b2e78bed5f148798-audit.json @@ -19,6 +19,11 @@ "date": 1781221952807, "name": "D:\\40. 개발소스\\04. PM\\pm_ver4\\trunk\\PM_ver4\\logs\\2026-06-12.exception.log", "hash": "ba924d93ffb0554955898f10d31aec8941f168e2751c34b3973ff887c03238bb" + }, + { + "date": 1781483235835, + "name": "D:\\40. 개발소스\\04. PM\\pm_ver4\\trunk\\PM_ver4\\logs\\2026-06-15.exception.log", + "hash": "11b2e04cffe03e4f31cf1e53fa6682fb6c1a12e4fb5cf7799e90729ad81b1d0b" } ], "hashType": "sha256" diff --git a/logs/.ca4432f26b21ac44b7757d5574f2dc85546b9be4-audit.json b/logs/.ca4432f26b21ac44b7757d5574f2dc85546b9be4-audit.json index 8c36bfe..f6e7051 100644 --- a/logs/.ca4432f26b21ac44b7757d5574f2dc85546b9be4-audit.json +++ b/logs/.ca4432f26b21ac44b7757d5574f2dc85546b9be4-audit.json @@ -19,6 +19,11 @@ "date": 1781221952798, "name": "D:\\40. 개발소스\\04. PM\\pm_ver4\\trunk\\PM_ver4\\logs\\2026-06-12.error.log", "hash": "7039aa67423dc6975cf9c4d156020027e9da902506d67d20b1ae3820b3f9ccff" + }, + { + "date": 1781483235833, + "name": "D:\\40. 개발소스\\04. PM\\pm_ver4\\trunk\\PM_ver4\\logs\\2026-06-15.error.log", + "hash": "1030626feeefe2d3b4a2d169ecc3774fb59d61b8573c967313dec48f1ab60bc5" } ], "hashType": "sha256" diff --git a/logs/2026-06-12.exception.log b/logs/2026-06-12.exception.log index 96dd621..c1fa103 100644 --- a/logs/2026-06-12.exception.log +++ b/logs/2026-06-12.exception.log @@ -2,3 +2,15 @@ TypeError: Cannot read properties of undefined (reading 'release') at module.exports (D:\40. 개발소스\04. PM\pm_ver4\trunk\PM_ver4\db\index.js:12:16) at process.processTicksAndRejections (node:internal/process/task_queues:104:5) +2026-06-12 17:40:31 [error 테스트: ] error: uncaughtException: listen EADDRINUSE: address already in use 0.0.0.0:6565 +Error: listen EADDRINUSE: address already in use 0.0.0.0:6565 + at Server.setupListenHandle [as _listen2] (node:net:2008:16) + at listenInCluster (node:net:2065:12) + at node:net:2274:7 + at process.processTicksAndRejections (node:internal/process/task_queues:90:21) +2026-06-12 17:40:39 [error 테스트: ] error: uncaughtException: listen EADDRINUSE: address already in use 0.0.0.0:6565 +Error: listen EADDRINUSE: address already in use 0.0.0.0:6565 + at Server.setupListenHandle [as _listen2] (node:net:2008:16) + at listenInCluster (node:net:2065:12) + at node:net:2274:7 + at process.processTicksAndRejections (node:internal/process/task_queues:90:21) diff --git a/logs/2026-06-15.error.log b/logs/2026-06-15.error.log new file mode 100644 index 0000000..e69de29 diff --git a/logs/2026-06-15.exception.log b/logs/2026-06-15.exception.log new file mode 100644 index 0000000..d28800f --- /dev/null +++ b/logs/2026-06-15.exception.log @@ -0,0 +1,85 @@ +2026-06-15 09:27:15 [error 테스트: ] error: uncaughtException: Cannot read properties of undefined (reading 'release') +TypeError: Cannot read properties of undefined (reading 'release') + at module.exports (D:\40. 개발소스\04. PM\pm_ver4\trunk\PM_ver4\db\index.js:12:16) + at process.processTicksAndRejections (node:internal/process/task_queues:104:5) +2026-06-15 09:29:22 [error 테스트: ] error: uncaughtException: Cannot read properties of undefined (reading 'release') +TypeError: Cannot read properties of undefined (reading 'release') + at module.exports (D:\40. 개발소스\04. PM\pm_ver4\trunk\PM_ver4\db\index.js:12:16) + at process.processTicksAndRejections (node:internal/process/task_queues:104:5) +2026-06-15 09:31:27 [error 테스트: ] error: uncaughtException: (no error message) +AggregateError [ECONNREFUSED]: + at D:\40. 개발소스\04. PM\pm_ver4\trunk\PM_ver4\node_modules\pg-pool\index.js:45:11 + at process.processTicksAndRejections (node:internal/process/task_queues:104:5) + at async Strategy._verify (D:\40. 개발소스\04. PM\pm_ver4\trunk\PM_ver4\passport\localStrategy.js:17:22) +2026-06-15 09:31:28 [error 테스트: ] error: uncaughtException: (no error message) +AggregateError [ECONNREFUSED]: + at D:\40. 개발소스\04. PM\pm_ver4\trunk\PM_ver4\node_modules\pg-pool\index.js:45:11 + at process.processTicksAndRejections (node:internal/process/task_queues:104:5) + at async Strategy._verify (D:\40. 개발소스\04. PM\pm_ver4\trunk\PM_ver4\passport\localStrategy.js:17:22) +2026-06-15 09:39:57 [error 테스트: ] error: uncaughtException: (no error message) +AggregateError [ECONNREFUSED]: + at D:\40. 개발소스\04. PM\pm_ver4\trunk\PM_ver4\node_modules\pg-pool\index.js:45:11 + at process.processTicksAndRejections (node:internal/process/task_queues:104:5) + at async Strategy._verify (D:\40. 개발소스\04. PM\pm_ver4\trunk\PM_ver4\passport\localStrategy.js:17:22) +2026-06-15 09:39:58 [error 테스트: ] error: uncaughtException: (no error message) +AggregateError [ECONNREFUSED]: + at D:\40. 개발소스\04. PM\pm_ver4\trunk\PM_ver4\node_modules\pg-pool\index.js:45:11 + at process.processTicksAndRejections (node:internal/process/task_queues:104:5) + at async Strategy._verify (D:\40. 개발소스\04. PM\pm_ver4\trunk\PM_ver4\passport\localStrategy.js:17:22) +2026-06-15 09:40:00 [error 테스트: ] error: uncaughtException: (no error message) +AggregateError [ECONNREFUSED]: + at D:\40. 개발소스\04. PM\pm_ver4\trunk\PM_ver4\node_modules\pg-pool\index.js:45:11 + at process.processTicksAndRejections (node:internal/process/task_queues:104:5) + at async Strategy._verify (D:\40. 개발소스\04. PM\pm_ver4\trunk\PM_ver4\passport\localStrategy.js:17:22) +2026-06-15 09:41:06 [error 테스트: ] error: uncaughtException: Cannot read properties of undefined (reading 'release') +TypeError: Cannot read properties of undefined (reading 'release') + at module.exports (D:\40. 개발소스\04. PM\pm_ver4\trunk\PM_ver4\db\index.js:12:16) + at process.processTicksAndRejections (node:internal/process/task_queues:104:5) +2026-06-15 11:06:01 [error 테스트: ] error: uncaughtException: Route.get() requires a callback function but got a [object Undefined] +Error: Route.get() requires a callback function but got a [object Undefined] + at Route. [as get] (D:\40. 개발소스\04. PM\pm_ver4\trunk\PM_ver4\node_modules\express\lib\router\route.js:216:15) + at proto. [as get] (D:\40. 개발소스\04. PM\pm_ver4\trunk\PM_ver4\node_modules\express\lib\router\index.js:521:19) + at Object. (D:\40. 개발소스\04. PM\pm_ver4\trunk\PM_ver4\routes\admin\adminRouter.js:32:8) + at Module._compile (node:internal/modules/cjs/loader:1854:14) + at Object..js (node:internal/modules/cjs/loader:1985:10) + at Module.load (node:internal/modules/cjs/loader:1577:32) + at Module._load (node:internal/modules/cjs/loader:1379:12) + at wrapModuleLoad (node:internal/modules/cjs/loader:255:19) + at Module.require (node:internal/modules/cjs/loader:1600:12) + at require (node:internal/modules/helpers:153:16) +2026-06-15 11:06:19 [error 테스트: ] error: uncaughtException: Route.get() requires a callback function but got a [object Undefined] +Error: Route.get() requires a callback function but got a [object Undefined] + at Route. [as get] (D:\40. 개발소스\04. PM\pm_ver4\trunk\PM_ver4\node_modules\express\lib\router\route.js:216:15) + at proto. [as get] (D:\40. 개발소스\04. PM\pm_ver4\trunk\PM_ver4\node_modules\express\lib\router\index.js:521:19) + at Object. (D:\40. 개발소스\04. PM\pm_ver4\trunk\PM_ver4\routes\admin\adminRouter.js:32:8) + at Module._compile (node:internal/modules/cjs/loader:1854:14) + at Object..js (node:internal/modules/cjs/loader:1985:10) + at Module.load (node:internal/modules/cjs/loader:1577:32) + at Module._load (node:internal/modules/cjs/loader:1379:12) + at wrapModuleLoad (node:internal/modules/cjs/loader:255:19) + at Module.require (node:internal/modules/cjs/loader:1600:12) + at require (node:internal/modules/helpers:153:16) +2026-06-15 11:58:31 [error 테스트: ] error: uncaughtException: Cannot read properties of undefined (reading 'message') +TypeError: Cannot read properties of undefined (reading 'message') + at exports.createFolder (D:\40. 개발소스\04. PM\pm_ver4\trunk\PM_ver4\controllers\archiveController.js:2363:29) + at process.processTicksAndRejections (node:internal/process/task_queues:104:5) +2026-06-15 12:00:00 [error 테스트: ] error: uncaughtException: Cannot read properties of undefined (reading 'message') +TypeError: Cannot read properties of undefined (reading 'message') + at exports.createFolder (D:\40. 개발소스\04. PM\pm_ver4\trunk\PM_ver4\controllers\archiveController.js:2363:29) + at process.processTicksAndRejections (node:internal/process/task_queues:104:5) +2026-06-15 12:00:23 [error 테스트: ] error: uncaughtException: listen EADDRINUSE: address already in use 0.0.0.0:6565 +Error: listen EADDRINUSE: address already in use 0.0.0.0:6565 + at Server.setupListenHandle [as _listen2] (node:net:2008:16) + at listenInCluster (node:net:2065:12) + at node:net:2274:7 + at process.processTicksAndRejections (node:internal/process/task_queues:90:21) +2026-06-15 12:00:43 [error 테스트: ] error: uncaughtException: listen EADDRINUSE: address already in use 0.0.0.0:6565 +Error: listen EADDRINUSE: address already in use 0.0.0.0:6565 + at Server.setupListenHandle [as _listen2] (node:net:2008:16) + at listenInCluster (node:net:2065:12) + at node:net:2274:7 + at process.processTicksAndRejections (node:internal/process/task_queues:90:21) +2026-06-15 12:12:30 [error 테스트: ] error: uncaughtException: Cannot read properties of undefined (reading 'message') +TypeError: Cannot read properties of undefined (reading 'message') + at exports.removeTarget (D:\40. 개발소스\04. PM\pm_ver4\trunk\PM_ver4\controllers\archiveController.js:2895:33) + at process.processTicksAndRejections (node:internal/process/task_queues:104:5) diff --git a/routes/admin/adminRouter.js b/routes/admin/adminRouter.js index ea5c56c..4ff0834 100644 --- a/routes/admin/adminRouter.js +++ b/routes/admin/adminRouter.js @@ -28,6 +28,11 @@ router.post('/permissions/assign', adminController.assignPermissions); router.put('/permissions/update', adminController.updatePermission); router.delete('/permissions/remove', adminController.removePermission); +// 2-1. Folder-Level Permissions +router.get('/permissions/folders/:projectId', adminController.getFolderPermissions); +router.post('/permissions/folders/assign', adminController.assignFolderPermissions); +router.delete('/permissions/folders/remove', adminController.removeFolderPermission); + // 3. Banners router.get('/banners', adminController.getBanners); router.post('/banners', adminController.createBanner); diff --git a/views/admin/dashboard.html b/views/admin/dashboard.html index aa50b6c..03b7412 100644 --- a/views/admin/dashboard.html +++ b/views/admin/dashboard.html @@ -1,3 +1,11 @@ + @@ -631,11 +639,12 @@ @@ -940,21 +949,65 @@ + +
+
+ +
+
+

📂 폴더 구조 (1~3단계 제한)

+
+
+ + +
+
+
프로젝트를 선택하시면 폴더 트리가 여기에 로드됩니다.
+
+
+ + +
+
+
+

👥 폴더별 사용자 권한 설정

+

좌측 트리에서 폴더를 선택해 주세요.

+
+
+
+ + + + + + + + + + + + + + + + +
사용자 ID이름부서/직급프로젝트 기본권한폴더 권한 등급설정
폴더를 선택하시면 사용자의 개별 권한 지정 목록이 여기에 표시됩니다.
+
+
+
+
+
-

🔎 시스템 민감 파일 감사 로그 조회 (tb_log)

+

🔎 시스템 활동 로그 조회 (tb_log)

- - - + + + +
@@ -971,7 +1024,7 @@ - +
감사 로그 데이터를 조회 중입니다...활동 로그 데이터를 조회 중입니다...
@@ -1430,7 +1483,8 @@ 'project-mgmt': '🏗️ 프로젝트 관리', 'banner-notice': '📢 실시간 배너 공지', 'user-mgmt': '👥 사용자 관리', - 'audit-logs': '🔎 감사 로그 조회', + 'folder-permission': '📂 폴더별 권한 관리', + 'audit-logs': '🔎 활동 로그 조회', 'delete-policy': '⚙️ 보관 및 삭제 정책 설정', 'code-mgmt': '🔑 공통 코드 관리' }; @@ -1441,6 +1495,7 @@ else if (tabId === 'project-mgmt') renderProjects(); else if (tabId === 'banner-notice') renderBanners(); else if (tabId === 'user-mgmt') renderUsers(); + else if (tabId === 'folder-permission') initFolderPermissionTab(); else if (tabId === 'audit-logs') renderAuditLogs(); else if (tabId === 'delete-policy') renderDeletePolicy(); else if (tabId === 'code-mgmt') renderCommonCodes(); @@ -2127,6 +2182,33 @@ } } + async function submitCreateUser(event) { + event.preventDefault(); + const payload = { + user_id: document.getElementById('form-user-id').value.trim(), + user_pw: document.getElementById('form-user-pw').value, + user_nm: document.getElementById('form-user-nm').value.trim(), + company: document.getElementById('form-user-company').value.trim(), + dept: document.getElementById('form-user-dept').value.trim(), + position: document.getElementById('form-user-position').value.trim(), + group: document.getElementById('form-user-group').value, + is_resigned: document.getElementById('form-user-resigned').value === 'true' + }; + + try { + await fetchAPI('/api/admin/users', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload) + }); + alert('신규 사용자가 등록되었습니다.'); + closeUserModal(); + renderUsers(); + } catch (err) { + console.error(err); + } + } + function closeUserModal() { document.getElementById('userModalOverlay').style.display = 'none'; } @@ -2176,14 +2258,18 @@ } } - // --- 7. 감사 로그 조회 탭 (Audit Logs) --- + // --- 7. 활동 로그 조회 탭 (Activity Logs) --- async function renderAuditLogs() { const userId = document.getElementById('search-log-user').value.trim(); - const action = document.getElementById('filter-log-action').value; + const projectNm = document.getElementById('search-log-project').value.trim(); + const action = document.getElementById('filter-log-action').value.trim(); - let queryUrl = '/api/admin/audit-logs?'; - if (userId) queryUrl += `user_id=${userId}&`; - if (action) queryUrl += `activity=${action}`; + const params = new URLSearchParams(); + if (userId) params.append('user_id', userId); + if (projectNm) params.append('project_nm', projectNm); + if (action) params.append('activity', action); + + let queryUrl = `/api/admin/audit-logs?${params.toString()}`; try { const logs = await fetchAPI(queryUrl); @@ -2191,7 +2277,7 @@ body.innerHTML = ''; if (logs.length === 0) { - body.innerHTML = `조회된 감사 로그 내역이 없습니다.`; + body.innerHTML = `조회된 활동 로그 내역이 없습니다.`; return; } @@ -2206,7 +2292,7 @@ tr.innerHTML = ` ${idx + 1} ${log.clean_date ? log.clean_date.replace('T', ' ').substring(0, 19) : '-'} - ${log.project_id || '-'} + ${log.project_nm || log.project_id || '-'} ${log.user_id || '-'} ${log.user_ip || '-'} ${log.clean_path || '-'} @@ -2604,6 +2690,303 @@ } } + // --- 9. 폴더별 권한 관리 탭 (Folder Permission) --- + let selectedFolderPermProjectId = null; + let selectedFolderPermPathKey = null; + let folderPermDataCache = { + folders: [], + folderPermissions: [], + users: [] + }; + + async function initFolderPermissionTab() { + const selectEl = document.getElementById('folder-perm-project-select'); + selectEl.innerHTML = ''; + + try { + const projects = await fetchAPI('/api/admin/projects'); + projects.forEach(p => { + const option = document.createElement('option'); + option.value = p.project_id; + option.innerText = `[${p.project_id}] ${p.project_nm || p.task_nm_kr}`; + selectEl.appendChild(option); + }); + + selectedFolderPermProjectId = null; + selectedFolderPermPathKey = null; + document.getElementById('folder-tree-container').innerHTML = ` +
프로젝트를 선택하시면 폴더 트리가 여기에 로드됩니다.
+ `; + resetFolderUserPermissionTable(); + } catch (err) { + console.error("initFolderPermissionTab error:", err); + } + } + + async function loadFolderStructure() { + const selectEl = document.getElementById('folder-perm-project-select'); + const projectId = selectEl.value; + selectedFolderPermProjectId = projectId; + selectedFolderPermPathKey = null; + resetFolderUserPermissionTable(); + + if (!projectId) { + document.getElementById('folder-tree-container').innerHTML = ` +
프로젝트를 선택하시면 폴더 트리가 여기에 로드됩니다.
+ `; + return; + } + + document.getElementById('folder-tree-container').innerHTML = ` +
폴더 데이터를 불러오는 중...
+ `; + + try { + const data = await fetchAPI(`/api/admin/permissions/folders/${projectId}`); + folderPermDataCache = data; + renderFolderTree(); + } catch (err) { + console.error(err); + document.getElementById('folder-tree-container').innerHTML = ` +
데이터 로드 실패: ${err.message}
+ `; + } + } + + function renderFolderTree() { + const container = document.getElementById('folder-tree-container'); + container.innerHTML = ''; + + const folders = folderPermDataCache.folders || []; + const folderPerms = folderPermDataCache.folderPermissions || []; + + // 1. Build tree structure to preserve parent-child relation while sorting siblings + const depth1Map = {}; + const depth2Map = {}; + const rootNodes = []; + + // data_depth 기준 오름차순 정렬하여 부모 노드가 맵에 먼저 등록되도록 보장 + const sortedByDepthFolders = [...folders].sort((a, b) => Number(a.data_depth) - Number(b.data_depth)); + + sortedByDepthFolders.forEach(f => { + const node = { + folder: f, + children: [], + name: f.data_depth === 1 ? f.path1 : (f.data_depth === 2 ? f.path2 : f.path3), + pathKey: f.data_depth === 1 ? f.path1 : (f.data_depth === 2 ? `${f.path1}/${f.path2}` : `${f.path1}/${f.path2}/${f.path3}`) + }; + + if (f.data_depth === 1) { + depth1Map[f.path1] = node; + rootNodes.push(node); + } else if (f.data_depth === 2) { + depth2Map[`${f.path1}/${f.path2}`] = node; + const parent = depth1Map[f.path1]; + if (parent) parent.children.push(node); + else rootNodes.push(node); + } else if (f.data_depth === 3) { + const parent = depth2Map[`${f.path1}/${f.path2}`]; + if (parent) parent.children.push(node); + else rootNodes.push(node); + } + }); + + // 2. Sort siblings by data_permission (1 -> 4 -> 8 -> 0) then name + function getSortWeight(perm) { + const p = Number(perm); + if (p === 1) return 1; // 상속폴더 (Viewer) + if (p === 4) return 2; // 일반 (Worker) + if (p === 8) return 3; // 보안 (Security) + if (p === 0) return 4; // 관리 (Sub-Master) + return 5; + } + + function sortNodes(nodeList) { + nodeList.sort((a, b) => { + const wa = getSortWeight(a.folder.data_permission); + const wb = getSortWeight(b.folder.data_permission); + if (wa !== wb) { + return wa - wb; + } + return a.name.localeCompare(b.name, 'ko'); + }); + + nodeList.forEach(node => { + if (node.children.length > 0) { + sortNodes(node.children); + } + }); + } + + sortNodes(rootNodes); + + // 3. Preorder traversal to flatten tree + const sortedFolders = []; + function traverse(nodeList) { + nodeList.forEach(node => { + sortedFolders.push(node.folder); + if (node.children.length > 0) { + traverse(node.children); + } + }); + } + traverse(rootNodes); + + if (sortedFolders.length === 0) { + container.innerHTML = `
생성된 폴더가 없습니다.
`; + return; + } + + const treeWrapper = document.createElement('div'); + treeWrapper.style.display = 'flex'; + treeWrapper.style.flexDirection = 'column'; + treeWrapper.style.gap = '4px'; + + sortedFolders.forEach(f => { + let pathKey = f.path1; + if (f.data_depth === 2) pathKey = `${f.path1}/${f.path2}`; + else if (f.data_depth === 3) pathKey = `${f.path1}/${f.path2}/${f.path3}`; + + const folderName = f.data_depth === 1 ? f.path1 : (f.data_depth === 2 ? f.path2 : f.path3); + const isInherited = !folderPerms.some(p => p.folder_path_key === pathKey); + + const itemDiv = document.createElement('div'); + itemDiv.style.display = 'flex'; + itemDiv.style.alignItems = 'center'; + itemDiv.style.padding = '8px 12px'; + itemDiv.style.borderRadius = 'var(--radius-md)'; + itemDiv.style.cursor = 'pointer'; + itemDiv.style.marginLeft = `${(f.data_depth - 1) * 20}px`; + itemDiv.style.background = selectedFolderPermPathKey === pathKey ? 'var(--primary-soft)' : 'transparent'; + itemDiv.style.border = selectedFolderPermPathKey === pathKey ? '1px solid var(--border)' : '1px solid transparent'; + itemDiv.style.transition = 'var(--transition)'; + + itemDiv.innerHTML = ` + 📂 + ${folderName} + ${isInherited ? '상속' : '설정'} + ${f.data_depth}단계 + ${f.data_permission ?? 0} + `; + + itemDiv.onclick = () => { + selectedFolderPermPathKey = pathKey; + renderFolderTree(); + renderFolderUserPermissions(pathKey); + }; + + treeWrapper.appendChild(itemDiv); + }); + + container.appendChild(treeWrapper); + } + + function resetFolderUserPermissionTable() { + document.getElementById('folder-perm-detail-title').innerText = '👥 폴더별 사용자 권한 설정'; + document.getElementById('folder-perm-detail-desc').innerText = '좌측 트리에서 폴더를 선택해 주세요.'; + document.getElementById('folder-perm-user-body').innerHTML = ` + + 폴더를 선택하시면 사용자의 개별 권한 지정 목록이 여기에 표시됩니다. + + `; + } + + function renderFolderUserPermissions(pathKey) { + document.getElementById('folder-perm-detail-title').innerText = `📂 [${pathKey}] 권한 설정`; + document.getElementById('folder-perm-detail-desc').innerText = '이 폴더에 대한 사용자의 접근 등급을 설정합니다. (미설정 시 프로젝트 기본 권한 적용)'; + + const tbody = document.getElementById('folder-perm-user-body'); + tbody.innerHTML = ''; + + const users = folderPermDataCache.users || []; + const folderPerms = folderPermDataCache.folderPermissions || []; + + if (users.length === 0) { + tbody.innerHTML = `이 프로젝트에 참여 중인 사용자가 없습니다.`; + return; + } + + users.forEach(u => { + const fp = folderPerms.find(p => p.folder_path_key === pathKey && p.user_id === u.user_id); + const currentLev = fp ? fp.lev : null; + + const tr = document.createElement('tr'); + + const projectLevName = getPermissionLabel(u.project_lev); + const folderLevName = currentLev !== null ? getPermissionLabel(currentLev) : '상속 (Inherited)'; + + tr.innerHTML = ` + ${u.user_id} + ${u.user_nm} + ${u.company} / ${u.dept} ${u.position} + ${projectLevName} + ${folderLevName} + + + + + `; + + tbody.appendChild(tr); + }); + } + + function getPermissionLabel(lev) { + if (lev === 255) return '관리자 (Admin)'; + if (lev === 191) return '부관리자 (Sub-Master)'; + if (lev === 8) return '보안참여자 (Security)'; + if (lev === 7) return '일반참여자 (Worker)'; + if (lev === 4) return '일반참여자 (Worker)'; + if (lev === 1) return '참관자 (Viewer)'; + if (lev === 0) return '접근 차단 (Block)'; + return `레벨 ${lev}`; + } + + async function saveFolderUserPermission(userId, btnEl) { + const selectEl = btnEl.previousElementSibling; + const value = selectEl.value; + + try { + if (value === 'inherit') { + await fetchAPI('/api/admin/permissions/folders/remove', { + method: 'DELETE', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + project_id: selectedFolderPermProjectId, + folder_path_key: selectedFolderPermPathKey, + user_id: userId + }) + }); + alert(`${userId}님의 폴더 권한이 제거되었으며, 프로젝트 기본 권한으로 초기화되었습니다.`); + } else { + await fetchAPI('/api/admin/permissions/folders/assign', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + project_id: selectedFolderPermProjectId, + folder_path_key: selectedFolderPermPathKey, + user_id: userId, + lev: Number(value) + }) + }); + alert(`${userId}님의 폴더 권한이 [${getPermissionLabel(Number(value))}] 등급으로 지정되었습니다.`); + } + + const updatedData = await fetchAPI(`/api/admin/permissions/folders/${selectedFolderPermProjectId}`); + folderPermDataCache = updatedData; + renderFolderUserPermissions(selectedFolderPermPathKey); + } catch (err) { + console.error(err); + alert(`⚠️ 권한 저장에 실패하였습니다: ${err.message}`); + } + } + // --- 10. 초기 로딩 --- window.onload = function() { loadUserProfile(); diff --git a/views/main/jsm/archive/dataManager.js b/views/main/jsm/archive/dataManager.js index 670aa2c..f8c8b9d 100644 --- a/views/main/jsm/archive/dataManager.js +++ b/views/main/jsm/archive/dataManager.js @@ -12,7 +12,7 @@ import { } from './common.js'; import { toggleModal } from './modalManager.js' import { showNotification, toggleContextmenu, toggleContextFocusBox } from './eventManager.js' -import { renderMemo, resetViewer } from './pageRenderer.js' +import { renderMemo, resetViewer, preparePageRendering } from './pageRenderer.js' let listContainer = document.querySelector('.archive-main-center .list-container'); @@ -476,8 +476,33 @@ export async function createFolder(inputWrap, resourcePath, folderType) { let createFolderRes = await axios.post(`${vars.path_name}/createFolder`, { params: createFolderParams }); if (createFolderRes.data.message == 'createFolder_success') { console.log(createFolderRes.data.message); - // console.log(res.data.folderPath); - // console.log(res.data); + + // 폴더 생성 성공 시 로컬 브라우저 화면 즉시 갱신 (소켓 지연/연결끊김 대비) + let userCurPath = (vars.users && vars.socket && vars.users[vars.socket.id]?.curPath) || JSON.parse(vars.userInfoString).curPath || ''; + let extractedPath = extractPathByLength(userCurPath, 1); + + // 1. 헤더 버튼 갱신 + await preparePageRendering({ + scope: 'headerBtn', + from: 'createFolder - local', + resourcePath: userCurPath, + pushState: false + }); + + // 2. 트리 갱신 + await preparePageRendering({ + scope: 'tree', + resourcePath: extractedPath, + userCurPath: userCurPath, + pushState: false + }); + + // 3. 리스트 갱신 + await preparePageRendering({ + scope: 'list', + resourcePath: userCurPath, + pushState: false + }); } } } diff --git a/views/main/jsm/archive/socketManager.js b/views/main/jsm/archive/socketManager.js index 6433d0c..4ba4af0 100644 --- a/views/main/jsm/archive/socketManager.js +++ b/views/main/jsm/archive/socketManager.js @@ -43,6 +43,18 @@ const socket = io(); //// 소켓은 하나만... vars.socket = socket; +function getMyCurPath() { + if (vars.users && vars.users[socket.id] && vars.users[socket.id].curPath) { + return vars.users[socket.id].curPath; + } + if (vars.userInfoString) { + try { + return JSON.parse(vars.userInfoString).curPath || ''; + } catch (e) {} + } + return ''; +} + //// 접속자 정보 가져오기 export function getUsers() { vars.socket.emit('getUsers'); @@ -536,8 +548,7 @@ socket.on('createFolder_success', async (resultData) => { renderLog(); - let userCurPath; - if (vars.users[socket.id] && vars.users[socket.id].curPath) userCurPath = vars.users[socket.id].curPath; + let userCurPath = getMyCurPath(); let extractedPath = extractPathByLength(userCurPath, 1); // let resourcePath = resultData.resourcePath; @@ -578,6 +589,21 @@ socket.on('createFolder_success', async (resultData) => { let mainTreeItem = document.querySelector(`.archive-main-left .tree-container .tree-wrap .tree-item-wrap[data-resource-path="${vars.lastMainTreeItem.dataset.resourcePath}"]`) if (mainTreeItem) changeTreeItemStyle(mainTreeItem); } + + // 폴더 생성 시 중앙 리스트 영역(list) 즉시 갱신 추가 + let pageRanderingOptionList = { + scope: 'list', + resourcePath: userCurPath, + pushState: false, + debug: '폴더생성완료 - list' + } + await preparePageRendering(pageRanderingOptionList); + + if (vars.lastListItem) { + let lastListItemPath = vars.lastListItem.dataset.resourcePath; + let listItem = document.querySelector(`.archive-main-center .list-container .list-body .list-item[data-resource-path="${lastListItemPath}"]`); + if (listItem) changeListItemStyle(listItem); + } } }) diff --git a/테이블명세서.html b/테이블명세서.html index fd539fa..37aa312 100644 --- a/테이블명세서.html +++ b/테이블명세서.html @@ -314,6 +314,7 @@ tb_user (사용자) tb_project (프로젝트) tb_permission (권한 등급) + tb_folder_permission (폴더별 권한) tb_user_setting (개인 설정)
+ +
+
+
tb_folder_permission 폴더별 사용자 권한 설정
+
1~3단계 폴더별 정밀 권한 설정
+
+
1~3단계 최상단 폴더별로 사용자의 접근(조회, 쓰기, 관리, 차단) 권한 레벨을 세부 지정하여 식별 저장합니다.
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
컬럼명 (Column)타입 (Type)제약조건설명 및 바인딩 예시
folder_permission_idSERIALNot Null PK자동 증가 식별자 일련번호
project_idVARCHAR(50)Not Null FK (tb_project)프로젝트 식별용 참조 코드
folder_path_keyVARCHAR(500)Not Null1~3단계 폴더 경로 키 (예: 'path1' 또는 'path1/path2' 등)
user_idVARCHAR(50)Not Null FK (tb_user)사용자 마스터 외래키 참조
levINTEGERNot Null Default: 1폴더별 권한 등급값 (차단=0, 참관자/조회=1, 일반참여자/쓰기=7, 부관리자/관리=191)
create_dateTIMESTAMPDefault: NOW()생성 일시
mod_dateTIMESTAMPDefault: NOW()수정 일시
+
+