Files
ai_platform/README.md
dsyoon 073a8343dd feat: xavis ai_platform 기능 이전 및 ncue 환경 전환
xavis 소스·DB 스키마·활용사례/F-Scan/프롬프트 라이브러리 등 기능 반영.
@xavis.co.kr → @ncue.net, 관리자 토큰 ncue-admin, 런타임 data/ Git 추적 제외.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-26 22:27:48 +09:00

877 lines
51 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Web Platform (학습센터)
유튜브 링크와 PowerPoint(`.pptx`) 강의를 등록하고, 목록에서 클릭해 바로 시청할 수 있는 사내 학습센터 예제입니다.
**소스 저장소(Git):** [https://git.xavis.co.kr/AI_Innovation_Team/ai_platform](https://git.xavis.co.kr/AI_Innovation_Team/ai_platform)
---
## 접속 방법
**브라우저에서 열기 전에 반드시 터미널에서 서버를 실행해야 합니다.** (`npm start` 미실행 상태에서는 `http://localhost:8030`에 연결되지 않습니다.)
서버 실행 후 아래 주소로 접속합니다.
| 환경 | URL |
|------|-----|
| 로컬 기본 | [http://localhost:8030](http://localhost:8030) |
| 포트 변경 시 | `http://localhost:{PORT}` (예: `PORT=4000 npm start``http://localhost:4000`) |
- **메인 페이지** (`/`): 학습센터 뷰어 (강의 목록)
- **학습센터 뷰어** (`/learning`): 강의 검색/필터 + 카드 목록 (일반 사용자)
- **관리자** (`/admin`): 강의 등록(YouTube/PPT/웹 링크 등), 썸네일·AI 추가 등 통합 관리
- **강의 상세**: 카드 클릭 시 유튜브 재생 또는 PPT 뷰어
- **기타 메뉴**: 회사규정(`/chat` 리다이렉트), WM(`/wm` 리다이렉트), AI(`/ai-explore`), **프롬프트**(`/ai-explore/prompts`, 좌측 전역 메뉴), **AI 활용 사례**(`/ai-cases`), **대시보드**(`/dashboard`, AI 활용 사례 아래 메뉴), AX 과제 신청(`/ax-apply`)
- **관리자 이벤트 로그**: `http://localhost:8030/admin/thumbnail-events?token={ADMIN_TOKEN}`
### 접속이 안 될 때 (트러블슈팅)
1. **서버가 떠 있는지 확인**
프로젝트 루트에서 `npm start`를 실행하고, 터미널에 `Server started: http://localhost:8030` 같은 로그가 나오는지 확인합니다.
2. **포트 번호 확인**
`.env``PORT=...`가 있으면 **그 포트**로 접속합니다. (기본값은 `8030`)
3. **부팅 실패**
터미널에 `Failed to bootstrap:`이 나오면 프로세스가 종료되며 HTTP 서버가 뜨지 않습니다. 메시지를 확인한 뒤 `data/` 쓰기 권한, 디스크 여유, DB 설정 등을 점검합니다.
4. **포트 충돌**
다른 프로그램이 8030을 쓰는 경우 `PORT=8031 npm start`처럼 바꿔 실행하거나, **이미 떠 있는 Node 서버 프로세스를 종료**합니다. 구체적인 명령은 아래 **실행 방법** 절의 **기존 서버 프로세스 종료** 항목을 참고하세요.
5. **바인드 주소**
기본은 모든 네트워크 인터페이스(`0.0.0.0`)에서 수신합니다. 로컬 루프백만 쓰려면 `HOST=127.0.0.1 npm start`를 사용할 수 있습니다.
### 배포 서버: `git pull`이 `data/` 런타임 파일 때문에 막힐 때
원격이 `data/`(예: `data/ai-success-stories.json`)를 Git 추적에서 제외하는 쪽으로 바뀌는 동안, 서버에 **수정해 둔** 같은 경로가 있으면 *Your local changes to the following files would be overwritten by merge* 메시지와 함께 `git pull`이 중단될 수 있습니다.
- **권장:** 프로젝트 루트에서 `bash scripts/safe-git-pull.sh`를 실행합니다. 백업 → 로컬 변경만 Git 관점에서 정리 → `git pull` → 백업 복원 순서로, 서비스에 쓰는 JSON 내용을 유지합니다. `main`이 반영된 뒤에는 해당 파일이 **무시(ignore)** 대상이 되어, 이후에는 보통 `git pull`만으로 갱신됩니다.
- **수동:** 파일을 임시로 복사한 뒤 `git restore data/ai-success-stories.json`(구버전은 `git checkout -- data/ai-success-stories.json`) → `git pull` → 필요 시 다시 복사합니다.
---
## 핵심 기능
- 학습센터 UI (좌측 메뉴 + 상단 헤더 + 강의 카드 레이아웃)
- **AI 탐색** (`/ai-explore`): 전체 너비 레이아웃, AI 서비스 카드(회의록·체크리스트 등; **프롬프트 라이브러리**는 좌측 **프롬프트** 메뉴에서 진입)
- **회의록 AI** (`/ai-explore/meeting-minutes`): 텍스트 입력 탭에서 **회의 원문****`복사` 버튼**(생성 결과의 회의록·전사와 동일한 연한 녹색 스타일)으로 원문을 클립보드에 복사할 수 있습니다. 회의록 생성 시스템 프롬프트는 `lib/meeting-minutes.js``buildMeetingMinutesSystemPrompt`에서 구성하며, 기본 구조 중 「2) 참석·언급 인원」은 **표 없이 한 줄 쉼표 목록**(원문 근거 인명·직함·조직명)으로 안내합니다. 저장된 사용자 **추가 지시**(비어 두면 해당 블록 없음)만 시스템 프롬프트 우선 순위 블록에 합류합니다. 화면 **음성 업로드** 생성 시 처리 상태를 **1 업로드 / 2 전사 / 3 번역**(전사 결과를 회의록 형식으로 LLM이 정리하는 단계)으로 나누어 진행 표시 및 SSE 패딩·하트비트로 장시간 작업 안내를 제공합니다. 음성 UI는 브라우저에서 두 단계로 동작합니다. **`POST /api/meeting-minutes/prepare-audio`**(multipart 필드: `audio`, `title`, `meetingDate`, `model`, `whisperModel` · `whisperModel` 생략 시 서버 기본 전사는 `gpt-4o-transcribe`, 필요 시 **`OPENAI_WHISPER_MODEL`**) 요청 시 **XHR `upload.onprogress`**로 업로드 바이트를 표시하고, 수신 즉시 **`{ jobId }`** JSON을 돌려줍니다. 이어 같은 세션으로 **`GET /api/meeting-minutes/stream-audio/:jobId`**를 `fetch`(ReadableStream)로 열어 `text/event-stream` 전사·회의록 SSE를 **증분 수신**합니다. **업로드와 SSE가 한 HTTP 요청으로 묶이면**(레거시 `POST …/generate-audio`) 브라우저·역프록시가 응답 본문을 버퍼링해 진행이 멈춘 것처럼 보일 수 있어, 위 분리 플로를 권장합니다. 작업 메타는 `data/meeting-audio-job-meta/`(실행 시 생성, 저장소에서는 `data/`가 무시)에 두며 TTL은 **`MEETING_AUDIO_JOB_TTL_MS`**(미설정 시 약 1시간, 상한·하한은 코드 참고). **로드밸런스 뒤에 Node 인스턴스가 여러 대**이면 이 `data/` 디렉터리(또는 job 메타 저장소)와 업로드 디렉터리가 **인스턴스 간 공유**되어야 같은 `jobId`에 대해 prepare와 stream 요청이 같은 파일·메타를 읽을 수 있습니다. 클라이언트에서는 `consumeSseFromFetch`가 이벤트마다 **`mmAudioStreamHandlers.onSseEvent`**를 호출합니다(`views/meeting-minutes.ejs`). 디버깅 시 같은 페이지 콘솔에서 `getMeetingMinutesAudioDiag()`를 호출하면 `uploadPhase`, `lastSseEvent`, 세그먼트 진행 등을 확인할 수 있습니다. 회의 결과 저장 시 `lib/parse-checklist-from-minutes.js`**액션 아이템 마크다운 표**(GFM 권장)와 번호 목록을 규칙으로 읽은 뒤 `extractChecklistStructured`(LLM) 결과와 서버에서 병합하여 업무 체크리스트로 반영합니다(`MEETING_AUTO_CHECKLIST=0` 등으로 끌 수 있음). 동일 처리 경로에서 `prepareMeetingMinutesForApi`**헤더·본문 열 수 불일치**(헤더 3열 대 데이터 4열 등)로 깨진 액션 표를 교정하고, 「담당」열에 들어 간 **발언자·저희·우리 팀** 등 통칭·역할 명칭 단독은 규칙 후처리 시 **미정**으로 바꿀 수 있습니다. DB `meeting_ai_prompts.include_checklist``true`일 때만 회의 체크리스트 강제 블록을 넣고, 기본값은 `false`입니다. `include_checklist``false`이면 생성 직후 `prepareMeetingMinutesForApi`에서 `## 회의 체크리스트` 등 블록을 **후처리로 제거**합니다(모델이 습관적으로 넣은 경우 대비). 기존 DB에 `include_checklist = true`가 남아 있으면 `UPDATE meeting_ai_prompts SET include_checklist = false`로 끄거나, 화면에서 **프롬프트 저장**으로 덮어씁니다.
- **임직원 인명 정규화**: `data/meeting-employee-names.txt`(또는 `MEETING_EMPLOYEE_NAMES_FILE`)에 성명을 두고, `lib/meeting-employee-names.js`가 전사·원문에서 **이름으로 보이는 토큰만** 명단과 퍼지 매칭해, 회의록 LLM 요청 **사용자 메시지 상단**에 짧은「이번 원문/전사 한정 · 임직원 표기 통일」블록만 붙입니다. 전 직원 명단을 시스템 프롬프트에 넣지 않습니다. 끄려면 `MEETING_NAME_NORMALIZATION=0`.
- **대시보드** (`/dashboard`): AI 탐색과 유사한 카드 그리드·검색으로 대시보드를 모아 표시. 첫 카드 **경영성과 대시보드**는 `/dashboard/business-performance`로 연결
- **경영성과 대시보드** (`/dashboard/business-performance`): **위쪽 대시보드 조회**(Chart.js 인라인), **아래 엑셀 업로드**(`.xlsx`, 매출일보 시트) 순서. 본문은 AI 프롬프트 페이지와 동일하게 **`main.container.container-ai-full`**(전체 너비·좌우 24px)로 맞춤. 대시보드 상단 **연도·분기**로 `mgmt_perf_uploads`에 저장된 해당 기간 **최신 스냅샷**을 불러오며, 쿼리 **`?year=2026&quarter=1`** 또는 폼 조회와 동일. 해당 기간 업로드가 없으면 기본 JSON 샘플을 쓰고 안내 문구를 표시합니다. `public/mgmt-perf/dashboard.css`에 있던 **범용 `.container { background: white }`** 는 앱 페이지에서 `main.container`까지 적용되어 회색 본문이 가려졌으므로 제거하고, 흰 카드는 **`.mgmt-perf-embed .container`** 만 사용합니다. 업로드는 DB(`mgmt_perf_uploads` / `mgmt_perf_snapshots`) 또는 DB 미연결 시 `data/mgmt-perf-last-state.json`에 스냅샷 저장. 최근 업로드 행 **`DELETE /api/mgmt-perf/upload/:id`** 로 삭제(PG는 CASCADE, 파일 전용 모드는 `id=file`). 단독 임베드 페이지는 `/dashboard/business-performance/embed`(본문에 `body.mgmt-perf-standalone`으로 어두운 배경). Express에서 **`/mgmt-perf/*``public/mgmt-perf/`** 정적 제공이 등록되어 있어 `dashboard-app.js`·`chart.umd.min.js`(CDN 대신 동봉)가 항상 같은 오리진에서 로드됩니다. 업로드 시 **한글 파일명**은 multer 기본(`defParamCharset` 생략 시 latin1)으로 온 `originalname`을 **`lib/decode-upload-filename.js`**의 `decodeUploadFilename`으로 보정합니다(`decodeURIComponent(escape(...))` 우선, 이어서 `Buffer` latin1→utf8). Busboy에 `defParamCharset: 'utf8'`를 켜면 이중 디코딩으로 깨질 수 있어 두지 않습니다. 탭 전환·차트 렌더는 **ASCII 섹션 id**(`mgmt-sec-sales` 등)와 `state.currentSection`으로 동기화합니다. 리버스 프록시로 **Apache2**를 쓰는 경우 업로드 실패·413이면 VirtualHost **`LimitRequestBody`**(예: 바이트 단위로 `deploy/apache-ai.ncue.net-ssl.conf.example`와 맞출 것)와 **`/api/`·`/mgmt-perf/` → Node** `ProxyPass` 전달 여부를 확인. (Nginx를 쓰는 환경이면 **`client_max_body_size`** 등을 해당 서버 설정에 맞춥니다.) 엑셀 집계 치환은 `npm install``xlsx` 설치 후 서버 재시작.
- **경영성과 데이터 확인**: 브라우저에서 `GET /api/mgmt-perf/status`(JSON)로 최근 스냅샷의 `payloadKeys`, `_uploadMeta`(행 수 등)를 확인할 수 있습니다. **현재 구현**은 엑셀에서 **매출일보 행 수·시트명만** `payload._uploadMeta`에 넣고, **차트 수치는 기본 시드 JSON**(`config/mgmt-perf-default-payload.json`)을 씁니다. 5,000행이어도 차트가 엑셀 집계와 일치하려면 **별도 집계·매핑 로직**이 필요합니다.
- **대시보드 메뉴 접근**: `.env``DASHBOARD_MENU_ALLOWED_EMAILS`**쉼표로 구분한 OPS 로그인 이메일**만 좌측 **대시보드** 메뉴·`/dashboard`·경영성과 API가 보입니다. 목록이 비어 있으면 누구에게도 표시되지 않습니다. 로컬(DEV)에서 관리자 토큰만 쓰는 경우 `DASHBOARD_MENU_DEV_USE_MEETING_EMAIL=1``MEETING_DEV_EMAIL`을 허용 목록과 맞추면 대조됩니다.
- **프롬프트 라이브러리** (`/ai-explore/prompts`): 좌측 **프롬프트** 메뉴로 진입 · 업무별 기본 프롬프트 카드 선택·미리보기·클립보드 복사 (`config/company-prompts.json`) · **공유하기** 탭의 본문은 **원문 보기** / **마크다운 보기** 토글(클라이언트 `marked` + `DOMPurify`) · **워크플로** 탭(①~④ 입력·초안 합치기·AI로 다듬기). **좌측 메뉴(채팅·AI·프롬프트·AI 활용 사례·학습센터·AX 과제 신청)는 관리자 여부와 관계없이 접근 가능**(강의 삭제·관리자 대시보드 등 일부 기능은 관리자 모드에서만)
- 검색/필터/페이지네이션
- 검색어(`q`) 기반 제목/설명/태그 필터
- 타입(`YouTube`, `PPT`) 필터
- 태그 필터 + 페이지네이션
- 유튜브 강의 등록/시청
- 유튜브 URL 입력 후 목록에 추가
- 강의 상세에서 iframe 임베드로 재생
- PPT 강의 등록/시청
- `.pptx` 파일 업로드
- 상세에서 슬라이드 이미지(PNG)만 표시(XML 텍스트 추출 목록은 표시하지 않음)
- PPT/PDF(`.pdf`) 상세: **1단·2단·3단** 보기 전환(그리드), 선택값은 브라우저 `localStorage`에 저장
- 목록 카드에 PPT 프리뷰(첫 슬라이드 제목 + 장수) 표시
- macOS 환경에서는 `qlmanage` 기반 실제 썸네일(첫 장 이미지) 자동 생성
- **업로드 동영상**(mp4/webm/mov 등): 목록 카드에 **ffmpeg**로 뽑은 대표 프레임 썸네일 표시(기본 약 0.5초 지점). 서버에 `ffmpeg`가 있어야 하며, PPT 썸네일과 동일한 백그라운드 큐·재시도 정책을 사용합니다.
- 썸네일 백그라운드 큐
- 썸네일 생성은 비동기 큐에서 처리
- 상태값: `pending` / `processing` / `ready` / `failed`
- 실패 시 자동 재시도 정책 적용(최대 횟수 이후 `failed` 고정)
- 큐 스냅샷을 `data/thumbnail-jobs.json`에 저장해 재시작 후 복구
- 작업 이벤트를 `data/thumbnail-events.json`에 기록
- 관리자 삭제
- 관리자 토큰으로 강의 삭제 가능
- 초기 샘플 데이터 시드
- `resources/lecture`에 있는 `.pptx`를 최초 실행 시 자동 등록
- **관리자 인증·바로가기(UI)**
- **좌측 메뉴 하단 `관리자` / `관리자 off`**: 운영(OPS)에서 **이메일 인증**으로 로그인한 임직원에게도 표시됩니다. **`관리자`**는 모달에서 `ADMIN_TOKEN`을 입력해 검증한 뒤 `/admin`으로 이동합니다(`POST /api/admin/validate-token`). **`관리자 off`**는 관리자 쿠키를 지우고 학습센터 목록(`/learning`)으로 돌아갑니다(`GET /admin/logout`). 이메일 미로그인 환경에서도 동일합니다. **이미 관리자 세션**이면 중복이므로 `관리자` 항목은 숨기고, **사용자 현황관리** → 구분선 → **관리자 off** →(OPS일 때 구분선)→ **로그아웃** 순으로 표시됩니다.
- **학습센터** (`/learning`): 관리자 쿠키가 있을 때 상단 오른쪽 **학습 등록**으로 통합 관리 화면(`/admin`)에 들어갈 수 있습니다. 이메일(OPS) 로그인과 동시에 있어도 버튼이 숨겨지지 않습니다.
- **AI 활용 사례** (`/ai-cases`): 관리자일 때 상단 **사례 등록·관리**로 편집 화면(`/ai-cases/write`)에 진입합니다(동일하게 OPS 로그인 중에도 표시).
---
## 프로젝트 구조
```text
ai_platform/
├─ server.js # Express 서버 진입점 (라우팅, 업로드, 썸네일 큐, DB 연동)
├─ package.json # 의존성 및 npm 스크립트
├─ .env # 환경 변수 (실제 값, .gitignore 대상)
├─ .env.example # 환경 변수 예시 템플릿
├─ db/
│ └─ schema.sql # PostgreSQL 스키마 (강의·회의록·경영성과·프롬프트 라이브러리 좋아요/공유 등, 기동 시 자동 적용)
├─ scripts/
│ ├─ apply-schema.js # 수동 스키마 적용 (npm run db:schema)
│ ├─ pg-backup.sh # PostgreSQL 논리 백업 (cron·npm run db:backup)
│ ├─ pg-restore.sh # 백업 복원 (npm run db:restore -- …)
│ └─ lib/load-env.sh # 셸 스크립트용 .env 로더
├─ public/
│ └─ styles.css # 전역 스타일(회의록 마크다운 뷰 `.mm-minutes-rendered` 표 셀 테두리 등)
├─ views/
│ ├─ partials/
│ │ ├─ nav.ejs # 좌측 공통 네비게이션(하단 관리자·토큰 모달 연동)
│ │ └─ admin-token-modal.ejs # 관리자 토큰 입력 모달(`openAdminTokenModal`)
│ ├─ learning-viewer.ejs # 학습센터 뷰어 (일반 사용자)
│ ├─ learning-admin.ejs # 학습센터 관리 (업로드·삭제·썸네일)
│ ├─ chat.ejs # OpenAI 기반 인앱 채팅 UI
│ ├─ ai-explore.ejs # AI 탐색 (전체 너비, 회의록 등 카드·검색; 프롬프트는 좌측 메뉴)
│ ├─ dashboard.ejs # 대시보드 목록(카드·검색)
│ ├─ dashboard-business-performance.ejs # 경영성과(업로드 + 인라인 Chart.js 조회)
│ ├─ mgmt_perf_embed.ejs # 경영성과 차트 단독 페이지(직접 열람·임베드용)
│ ├─ partials/mgmt_perf_dashboard_container.ejs
│ ├─ ai-prompts.ejs # 프롬프트 라이브러리 (카드·미리보기·복사)
│ ├─ ai-cases.ejs # AI 활용 사례 목록(카드·`data/ai-success-stories.json` + PG `ai_use_case_submissions` 병합)
│ ├─ ai-use-case-submission-detail.ejs # 일반 제출 상세(`GET /ai-cases/submit/:uuid`)
│ ├─ ai-case-detail.ejs # AI 활용 사례 상세(마크다운 또는 PDF·페이지 이미지, 1·2·3단 보기)
│ ├─ ai-cases-compose.ejs # AI 활용 사례 일반 글쓰기(TOAST UI WYSIWYG 한 칸, 1~4번 STAR는 편집기 안 템플릿, 제출 시 4필드로 분리·`sanitize-use-case-body` 정제)
│ ├─ ai-cases-write.ejs # AI 활용 사례 관리자 등록·편집
│ ├─ ax-apply.ejs # AX 과제 신청
│ ├─ lecture-youtube.ejs # 유튜브 강의 상세 (iframe 임베드)
│ ├─ lecture-ppt.ejs # PPT 강의 상세 (슬라이드 이미지)
│ └─ admin-thumbnail-events.ejs # 썸네일 이벤트 로그 관리자 페이지
├─ config/
│ ├─ company-prompts.json # AI 프롬프트 라이브러리 템플릿 (Git 추적)
│ └─ mgmt-perf-default-payload.json # 경영성과 대시보드 기본 차트 시드 (Git 추적)
├─ data/ # 런타임 JSON·업로드 메타 (Git 제외, `.gitkeep`만 추적)
│ ├─ lectures.json # 강의 DB (PG 미사용 시)
│ ├─ ai-success-stories.json / ai-success-stories/
│ ├─ thumbnail-jobs.json # 썸네일 큐 스냅샷
│ └─ thumbnail-events.json # 썸네일 이벤트 로그
├─ uploads/ # 업로드된 .pptx 파일 저장
│ └─ thumbnails/ # 생성된 썸네일 이미지
└─ resources/
└─ lecture/ # 초기 시드용 샘플 PPT (최초 실행 시 자동 등록)
```
### 구조 설명
| 구분 | 설명 |
|------|------|
| **서버** | `server.js`가 Express 앱, 라우트, Multer 업로드, 썸네일 백그라운드 워커, PostgreSQL 연동을 모두 담당 |
| **데이터 저장소** | `ENABLE_POSTGRES=1`이면 PostgreSQL `lectures` 테이블이 단일 소스, `0`이면 `data/lectures.json` 사용 |
| **Git과 데이터** | `data/`, `public/resources/ai-success/`, `public/files/`**.gitignore**로 제외(런타임·업로드 전용). `config/``company-prompts.json`·`mgmt-perf-default-payload.json`만 기본 템플릿으로 추적 |
| **썸네일** | 비동기 큐 처리, `data/thumbnail-jobs.json`으로 영속화, `data/thumbnail-events.json`에 이벤트 기록 |
| **뷰** | EJS 템플릿으로 메인/유튜브/PPT/관리자 페이지 렌더링 |
---
## 서버 배포 (새 머신·처음부터)
아래는 **저장소를 처음 받아 운영 서버에 올리는 경우**를 가정한 절차입니다. **macOS**(개발용·소규모 호스팅)와 **Linux**(일반적인 VPS·온프레미스 서버)를 구분했습니다.
### 공통 사전 요구사항
| 항목 | 설명 |
|------|------|
| **Node.js** | **v18 이상** 권장 (`node -v`로 확인) |
| **npm** | Node와 함께 설치 |
| **Git** | 저장소 클론용 |
| **PostgreSQL** | `ENABLE_POSTGRES=1` 사용 시 접속 가능한 DB(로컬 설치 또는 원격) |
| **환경 변수** | `.env.example`을 복사해 `.env` 작성(비밀번호·토큰은 반드시 변경) |
PPT 썸네일·슬라이드 이미지는 **macOS**에서는 `qlmanage`가 우선 사용되고, 그 외에는 **LibreOffice**·**poppler(`pdftoppm`)** 조합이 필요합니다. 도구가 없으면 텍스트 프리뷰로 동작합니다.
---
### macOS에서 배포 (새 머신)
1. **도구 설치**
- **Node.js**: [nodejs.org](https://nodejs.org/) LTS 설치, 또는 `brew install node@20`
- **Git**: Xcode Command Line Tools(`xcode-select --install`) 또는 `brew install git`
- **PostgreSQL**(로컬 DB를 쓸 때): `brew install postgresql@16` 후 서비스 기동, 또는 원격 DB만 사용
- **PPT 변환 강화(선택)**: `brew install --cask libreoffice`, `brew install poppler`
2. **코드 받기**
```bash
mkdir -p ~/workspace && cd ~/workspace
git clone <저장소 URL> ai_platform
cd ai_platform
```
(이미 클론한 경우 예: `cd /Users/dsyoon/workspace/ai_platform`)
3. **의존성·환경**
```bash
npm install
cp .env.example .env
# 편집기로 .env 수정: PORT, ADMIN_TOKEN, DB_*, ENABLE_POSTGRES 등
```
4. **DB 스키마** (`ENABLE_POSTGRES=1`일 때)
```bash
npm run db:schema
```
5. **실행 방식 선택**
- **포그라운드(테스트)**: `npm start` → 터미널에 `Server started: http://localhost:8030` 확인 후 브라우저 접속
- **백그라운드(PM2)**: 아래 「[PM2로 실행](#pm2로-실행)」 절 참고 (`pm2 start … --name ai_platform`)
6. **접속**
같은 Mac에서: `http://127.0.0.1:8030` (또는 `.env`의 `PORT`). 다른 기기에서 접속하려면 `HOST=0.0.0.0`이 기본이므로, **macOS 방화벽**에서 Node 허용 여부를 확인합니다.
---
### Linux에서 배포 (새 서버)
배포 경로는 예시로 `/var/www/ai_platform`을 둡니다. 배포 사용자·그룹(`www-data` 등)은 배포 정책에 맞게 조정하세요.
1. **시스템 패키지** (Ubuntu/Debian 예시)
```bash
sudo apt update
sudo apt install -y git build-essential
```
- **Node.js**: 배포판 기본 패키지가 오래된 경우가 많으므로 **[NodeSource](https://github.com/nodesource/distributions)** 또는 **nvm**으로 **v18+** 설치를 권장합니다.
- **PostgreSQL 클라이언트/서버**: 원격 DB만 쓰면 클라이언트 라이브러리만으로도 되고, 로컬 DB면 `postgresql` 패키지 설치 후 DB·사용자 생성.
- **PPT 변환(선택)**: `sudo apt install -y libreoffice poppler-utils`
- **PPT 슬라이드 이미지 한글(□·토푸)**: 변환은 서버에서 이루어지므로 **한글 글꼴**이 없으면 PNG에만 한글이 깨집니다(HTML UI 한글은 정상일 수 있음). 예: `sudo apt install -y fonts-nanum fonts-noto-cjk`, 이후 `sudo fc-cache -fv` 로 fontconfig 갱신 → 관리자 화면에서 해당 강의 **슬라이드 이미지 재생성**.
2. **코드 배치**
```bash
sudo mkdir -p /var/www
cd /var/www
sudo git clone <저장소 URL> ai_platform
cd ai_platform
sudo chown -R "$USER:$USER" /var/www/ai_platform # 이후 작업 사용자에 맞게 조정
```
3. **의존성·환경**
```bash
npm install --production
cp .env.example .env
nano .env # 또는 vim — PORT, ADMIN_TOKEN, DB_*, ENABLE_POSTGRES 등
```
4. **DB 스키마** (`ENABLE_POSTGRES=1`일 때)
```bash
npm run db:schema
```
5. **프로세스 관리 (PM2 권장)**
```bash
sudo npm install -g pm2
cd /var/www/ai_platform
pm2 start server.js --name ai_platform
pm2 save
pm2 startup # 부팅 시 자동 기동(출력되는 sudo 명령 실행)
```
6. **방화벽·리버스 프록시**
- 외부에 직접 `8030`을 열지 않고 **Apache2**(또는 동급 리버스 프록시)로 TLS·역방향 프록시하는 구성이 일반적입니다.
- **Apache2** 예시(모듈·VirtualHost·`LimitRequestBody`·`ProxyTimeout`)는 [docs/DEPLOYMENT-xavis.ncue.net.md](docs/DEPLOYMENT-xavis.ncue.net.md) 및 `deploy/apache-ai.ncue.net-ssl.conf.example`를 참고하세요.
- `ufw` 사용 시: `sudo ufw allow 80,443/tcp` 후 앱은 로컬에서만 듣게 하거나, 프록시 뒤에 둡니다.
7. **데이터 디렉터리 권한**
`data/`, `uploads/` 등에 앱 실행 사용자(예: `pm2`로 띄운 사용자, 또는 `www-data`)가 쓰기 가능해야 합니다. 실패 시 로그에 `Failed to bootstrap` 또는 권한 오류가 납니다.
---
### 배포 후 확인
- 터미널 또는 `pm2 logs ai_platform`에서 **에러 없이** `Server started` 로그 확인
- 브라우저로 메인·`/learning`·관리자(토큰)까지 동작 확인
- PostgreSQL 사용 시 `npm run db:schema`는 **최초 1회**(또는 스키마 변경 시); **일일 DB 백업**은 아래 「PostgreSQL 백업 및 복원」의 cron 절을 따릅니다.
---
## 실행 방법
**로컬에서 빠르게 띄우기**는 아래 순서면 됩니다. **새 서버에 맞춰 처음부터 배포**할 때는 위 「서버 배포 (새 머신·처음부터)」의 **macOS** 또는 **Linux** 절을 따르세요.
## 1) 의존성 설치
```bash
npm install
```
## 2) PostgreSQL 스키마 적용
```bash
npm run db:schema
```
## 3) 서버 실행
프로젝트 루트에서:
```bash
npm start
```
정상 기동 시 터미널에 `Server started: http://localhost:8030` (또는 설정한 `PORT`)가 출력됩니다. **이 상태에서만** 브라우저로 접속할 수 있습니다.
### PM2로 실행
프로젝트 루트에서 실행해야 `server.js`가 같은 디렉터리의 `.env`를 읽습니다( `dotenv` 기준).
**1) PM2 설치 (전역, 한 번)**
```bash
npm install -g pm2
```
**2) 프로젝트 디렉터리로 이동 후 의존성**
```bash
cd /Users/dsyoon/workspace/ai_platform
npm install
cp .env.example .env # 최초 1회 — 값 편집
```
**3) 앱 기동**
```bash
pm2 start server.js --name ai_platform
```
`npm start`와 동일하게 `server.js`를 띄웁니다. 이름만 PM2에서 `ai_platform`으로 관리합니다.
**4) 재부팅 후에도 유지 (선택)**
```bash
pm2 save
pm2 startup
```
`pm2 startup`이 출력하는 `sudo env PATH=...` 한 줄을 그대로 실행한 뒤, 다시 `pm2 save`를 하면 부팅 시 자동 기동에 맞춰집니다.
**5) 자주 쓰는 명령**
| 목적 | 명령 |
|------|------|
| 상태 확인 | `pm2 list` |
| 로그(실시간) | `pm2 logs ai_platform` |
| 재시작 | `pm2 restart ai_platform` |
| 중지 | `pm2 stop ai_platform` |
| 목록에서 제거 | `pm2 delete ai_platform` |
**환경 변수**: 포트·DB 등은 프로젝트 루트의 `.env`에 두고, 변경 후 `pm2 restart ai_platform`으로 반영합니다. 별도 경로에 두었다면 해당 디렉터리에서 `pm2 start` 하거나, `ecosystem` 설정으로 `cwd`를 지정하세요.
운영/배포 환경에서 이미 PM2로 띄운 경우 재시작 예:
```bash
pm2 restart ai_platform
```
로컬에서 포그라운드로만 확인할 때:
```bash
node server.js
```
- 기본 포트: `8030`
- 접속 URL: [http://localhost:8030](http://localhost:8030)
### 포트·수신 주소 변경
```bash
PORT=8030 npm start
```
```bash
# 로컬 루프백만 (외부 네트워크 인터페이스에 바인딩하지 않음)
HOST=127.0.0.1 npm start
```
서버는 기본적으로 `HOST=0.0.0.0`으로 바인딩합니다(동일 기기의 `localhost` 접속에 사용).
### 기존 서버 프로세스 종료
터미널을 닫았는데도 이전에 실행한 `node server.js`가 남아 있거나, **포트가 이미 사용 중(`EADDRINUSE`)**이라 새로 `npm start`가 실패할 때, 기존 프로세스를 먼저 종료합니다.
**포트로 PID 확인 후 종료 (macOS / Linux, 기본 포트 8030 예시)**
```bash
lsof -i :8030
```
출력의 `PID` 열 값을 확인한 뒤:
```bash
kill PID
```
응답이 없으면 `kill -9 PID`로 강제 종료할 수 있으나, 다른 Node 작업이 같은 포트를 쓰는지 확인한 뒤 사용하세요.
**프로젝트 진입점만 대상으로 종료 (다른 `node` 작업에 영향을 줄 수 있으니 주의)**
```bash
pkill -f "node server.js"
```
**PM2로 띄운 경우**
```bash
pm2 list
pm2 stop ai_platform
# 완전히 제거하려면
pm2 delete ai_platform
```
Windows에서는 작업 관리자에서 `Node.js` 프로세스를 종료하거나, PowerShell에서 `Get-NetTCPConnection -LocalPort 8030` 등으로 점유 프로세스를 확인한 뒤 해당 PID를 종료합니다.
### 관리자 토큰/페이지 크기 설정(선택)
```bash
ADMIN_TOKEN=my-secret PAGE_SIZE=12 npm start
```
- `ADMIN_TOKEN` 미지정 시 기본값: `ncue-admin`
- `PAGE_SIZE` 미지정 시 기본값: `8`
- `.env` 파일이 있으면 `dotenv`로 자동 로드
- 브라우저에서는 좌측 메뉴 하단 **관리자** → 모달에 위 토큰을 입력해 세션 쿠키를 발급받는 방식으로 `/admin`에 진입할 수 있습니다(임직원 이메일 로그인 여부와 동일한 흐름).
### PostgreSQL 연결 설정
- `ENABLE_POSTGRES=1`일 때: PostgreSQL이 **단일 소스**로 사용됩니다. `data/lectures.json`은 사용하지 않습니다.
- `ENABLE_POSTGRES=0`일 때: `data/lectures.json`만 사용합니다.
- DB 연결 실패 시 자동으로 `data/lectures.json` 기반 파일 저장소로 폴백합니다.
- 필수 변수: `DB_HOST`, `DB_PORT`, `DB_DATABASE`, `DB_USERNAME`, `DB_PASSWORD`
---
## PostgreSQL 백업 및 복원
운영 서버에서 **매일 cron**으로 PostgreSQL **서버의 연결 가능 DB 전체**를 백업하고, 장애·실수 삭제 시 복원하는 절차입니다.
기본(`PG_BACKUP_SCOPE=all`)은 템플릿 DB를 제외한 **모든 데이터베이스**를 DB마다 `{dbname}.dump` 파일로 저장합니다. 역할(계정)은 `PG_BACKUP_GLOBALS=1`과 슈퍼유저 비밀번호가 있으면 `00_globals.sql`로 함께 백업합니다.
### 개요
| 항목 | 내용 |
|------|------|
| 백업 범위(기본) | PostgreSQL 서버의 **모든 DB** (`template0`/`template1` 제외) |
| 백업 방식 | DB별 `pg_dump -Fc` + (선택) `pg_dumpall --globals-only` |
| 스크립트 | `scripts/pg-backup.sh`, `scripts/pg-restore.sh` |
| npm | `npm run db:backup`, `npm run db:restore -- <옵션> <파일 또는 디렉터리>` |
| 설정 | 프로젝트 루트 `.env`의 `DB_*`, `PG_BACKUP_*` |
| 저장 위치(기본) | `/home/xavis/workspace/backup/ai_platform/YYYYMMDD/` |
| 최신 심볼릭 링크 | `/home/xavis/workspace/backup/ai_platform/latest` → 가장 최근 백업 디렉터리 |
백업 파일에는 **각 DB의 테이블·데이터·인덱스·시퀀스**와(옵션) **역할·권한**이 포함됩니다. Git·공개 저장소에 올리지 마세요.
### 사전 요구사항
1. **PostgreSQL 클라이언트 도구** (`pg_dump`, `pg_restore`, `psql`)가 서버 PATH에 있어야 합니다.
- Ubuntu/Debian: `sudo apt install -y postgresql-client`
- macOS: `brew install libpq` 후 PATH에 `pg_dump` 추가
2. **`.env`**에 `DB_HOST`, `DB_PORT`, `DB_DATABASE`, `DB_USERNAME`, `DB_PASSWORD`가 앱과 동일하게 설정되어 있어야 합니다.
3. **전체 DB 백업**을 위해서는 `.env`에 `PG_BACKUP_SUPERUSER_PASSWORD`(postgres 등 슈퍼유저)를 설정하는 것을 권장합니다. 없으면 `DB_USERNAME`이 접근 가능한 DB만 백업됩니다.
4. **디스크 여유**: (모든 DB 크기 합) × 보관 일수(30) × 1.2 이상 권장.
### 환경 변수 (`.env`)
| 변수 | 필수 | 기본값 | 설명 |
|------|------|--------|------|
| `DB_HOST` | O | — | PostgreSQL 호스트 |
| `DB_PORT` | — | `5432` | 포트 |
| `DB_DATABASE` | O | — | 앱 DB 이름(복원·단일 백업 시 참고). `PG_BACKUP_SCOPE=single`일 때만 백업 대상 |
| `DB_USERNAME` | O | — | 앱 DB 계정 |
| `DB_PASSWORD` | O | — | 앱 DB 비밀번호 |
| `PG_BACKUP_DIR` | — | `/home/xavis/workspace/backup/ai_platform` | 백업 루트 디렉터리 |
| `PG_BACKUP_RETENTION_DAYS` | — | `30` | 이 일수보다 **오래된 날짜 폴더**(YYYYMMDD) 삭제. `0`이면 삭제 안 함 |
| `PG_BACKUP_SCOPE` | — | `all` | `all`=서버의 모든 DB, `single`=`DB_DATABASE`만 |
| `PG_BACKUP_GLOBALS` | — | `1` | `1`이면 역할(계정)·권한 SQL(`00_globals.sql`)도 함께 백업 |
| `PG_BACKUP_SUPERUSER` | — | `postgres` | globals 백업·전체 DB dump 시 사용 |
| `PG_BACKUP_SUPERUSER_PASSWORD` | 전체 백업 권장 | — | 슈퍼유저 비밀번호 |
**운영 서버 `.env` 예시 (백업 관련만):**
```env
PG_BACKUP_DIR=/home/xavis/workspace/backup/ai_platform
PG_BACKUP_RETENTION_DAYS=30
PG_BACKUP_SCOPE=all
PG_BACKUP_GLOBALS=1
PG_BACKUP_SUPERUSER=postgres
PG_BACKUP_SUPERUSER_PASSWORD=실제_슈퍼유저_비밀번호
```
`PG_BACKUP_SCOPE=single`로 바꾸면 예전처럼 `DB_DATABASE` 하나만 백업합니다.
### 수동 백업
프로젝트 루트에서:
```bash
npm run db:backup
# 또는
bash scripts/pg-backup.sh
```
성공 시 예시 출력:
```text
[2026-05-25T02:00:01+09:00] pg-backup start → /home/xavis/workspace/backup/ai_platform/20260525 (scope=all, retention 30 days)
[2026-05-25T02:00:02+09:00] globals saved: .../20260525/00_globals.sql
[2026-05-25T02:00:03+09:00] dumping database: ai_web_platform
[2026-05-25T02:00:15+09:00] dump saved: .../20260525/ai_web_platform.dump (12345678 bytes)
[2026-05-25T02:00:16+09:00] dumping database: postgres
[2026-05-25T02:00:17+09:00] dump saved: .../20260525/postgres.dump (456789 bytes)
[2026-05-25T02:00:17+09:00] retention: pruned 1 dir(s); keeping backups from 20260425 onward (30 days)
[2026-05-25T02:00:17+09:00] pg-backup done: 2 database(s), latest → .../latest
```
생성 파일:
```text
/home/xavis/workspace/backup/ai_platform/
├─ 20260525/
│ ├─ 00_globals.sql # 역할·권한 (PG_BACKUP_GLOBALS=1)
│ ├─ 00_manifest.txt # 백업 DB 목록
│ ├─ ai_web_platform.dump
│ ├─ postgres.dump
│ └─ … # 서버의 다른 DB마다 *.dump
└─ latest -> 20260525/ # 심볼릭 링크 (최대 30일치 폴더 유지)
```
### 매일 cron 등록 (운영 서버)
1. 백업 디렉터리 생성 및 권한:
```bash
sudo mkdir -p /home/xavis/workspace/backup/ai_platform
sudo chown "$(whoami):$(whoami)" /home/xavis/workspace/backup/ai_platform
# PM2/앱과 같은 사용자로 cron을 돌릴 경우 그 사용자로 chown
```
2. `.env`에 `PG_BACKUP_DIR=/home/xavis/workspace/backup/ai_platform` 설정.
3. crontab 편집:
```bash
crontab -e
```
4. **매일 새벽 2시** (프로젝트 경로는 배포 위치에 맞게 수정):
```cron
0 2 * * * cd /home/xavis/workspace/ai_platform && /usr/bin/bash scripts/pg-backup.sh >> /var/log/pg-backup.log 2>&1
```
5. 로그 로테이션(선택): `/var/log/pg-backup.log`가 커지지 않도록 `logrotate` 또는 주기적 truncate.
**cron 점검**
```bash
# 다음 실행 예약 확인
crontab -l
# 수동 1회 실행 후 로그 확인
cd /home/xavis/workspace/ai_platform && bash scripts/pg-backup.sh
tail -20 /var/log/pg-backup.log
# 최신 dump 목록 확인
ls -lh /home/xavis/workspace/backup/ai_platform/latest/
cat /home/xavis/workspace/backup/ai_platform/latest/00_manifest.txt
```
### 복원
`.dump` 파일은 **custom format** 전용입니다. plain SQL(`.sql`)이 아니면 `psql -f`가 아니라 `pg_restore`를 사용합니다.
#### 1) 검증용 복원 — DB 하나
```bash
npm run db:restore -- --test --confirm /home/xavis/workspace/backup/ai_platform/latest/ai_web_platform.dump
```
#### 1-2) 검증용 복원 — 서버 DB 전체
```bash
npm run db:restore -- --all --globals --test --confirm /home/xavis/workspace/backup/ai_platform/latest/
```
각 DB는 `{dbname}_restore_test` 이름으로 복원됩니다(예: `ai_web_platform_restore_test`).
확인:
```bash
psql -h "$DB_HOST" -U "$DB_USERNAME" -d ai_web_platform_restore_test -c "\dt"
psql -h "$DB_HOST" -U "$DB_USERNAME" -d ai_web_platform_restore_test \
-c "SELECT relname, n_live_tup FROM pg_stat_user_tables ORDER BY n_live_tup DESC LIMIT 10;"
```
검증 후 테스트 DB 삭제:
```bash
psql -h "$DB_HOST" -U postgres -c 'DROP DATABASE ai_web_platform_restore_test;'
```
(`DROP`은 슈퍼유저 또는 DB owner 권한 필요)
#### 2) 운영 DB 복원 — DB 하나 (주의)
```bash
pm2 stop webplatform # 앱 이름에 맞게
bash scripts/pg-restore.sh --clean --confirm /home/xavis/workspace/backup/ai_platform/latest/ai_web_platform.dump
pm2 start webplatform
```
#### 2-2) 운영 DB 복원 — 서버 DB 전체 (주의)
```bash
pm2 stop webplatform
bash scripts/pg-restore.sh --all --globals --clean --confirm /home/xavis/workspace/backup/ai_platform/latest/
pm2 start webplatform
```
- `--clean`: 기존 객체를 삭제한 뒤 dump 내용으로 재생성 (`--if-exists` 포함).
- 운영 DB에 `--clean` 없이 복원하면 중복 객체 오류가 날 수 있습니다.
- `--confirm` 없이 실행하면 대화형 확인 프롬프트가 뜹니다.
역할(계정)까지 함께 백업해 둔 경우(`00_globals.sql`):
```bash
bash scripts/pg-restore.sh --globals --clean --confirm /home/xavis/workspace/backup/ai_platform/.../ai_web_platform.dump
```
`.env`에 `PG_BACKUP_SUPERUSER`, `PG_BACKUP_SUPERUSER_PASSWORD` 필요.
#### 3) 완전히 새 DB로 복원 (드물게)
DB 자체를 drop/create한 뒤 복원할 때는 DBA·슈퍼유저로:
```bash
psql -h "$DB_HOST" -U postgres -c "DROP DATABASE IF EXISTS ai_web_platform;"
psql -h "$DB_HOST" -U postgres -c "CREATE DATABASE ai_web_platform OWNER xavis;"
bash scripts/pg-restore.sh --confirm /home/xavis/workspace/backup/ai_platform/.../ai_web_platform.dump
```
### 백업·복원 체크리스트 (월 1회 권장)
| Check | 방법 |
|-------|------|
| cron 정상 실행 | `/var/log/pg-backup.log`에 전일 `pg-backup done` 기록 |
| dump 파일 크기 | `00_manifest.txt`·각 `.dump`가 0 byte가 아닌지 확인 |
| 복원 테스트 | `--all --test` 또는 단일 `--test`로 `_restore_test` DB 확인 |
| 디스크 | `df -h $PG_BACKUP_DIR` |
| 보관 정책 | `PG_BACKUP_RETENTION_DAYS`에 맞게 오래된 폴더 삭제되는지 |
### 보안
- dump 파일은 **개인정보·업무 데이터 전체**를 담습니다. 권한 `600`/`700` 디렉터리, Git·Slack·메일 첨부 금지.
- 가능하면 백업 저장소를 **앱 서버와 분리**(NAS, 객체 스토리지)하고 주기적으로 오프사이트 복사.
- DB는 공인 IP 직접 노출 대신 **방화벽·VPN·SSH 터널** 사용(별도 보안 가이드 참고).
### 문제 해결
| 증상 | 조치 |
|------|------|
| `pg_dump: command not found` | `postgresql-client` 설치 |
| `password authentication failed` | `.env`의 `DB_USERNAME`/`DB_PASSWORD` 확인 |
| `could not connect to server` | `DB_HOST`·방화벽·PostgreSQL `listen_addresses` |
| cron은 도는데 파일 없음 | cron의 `cd` 경로, `.env` 존재, 로그 파일 stderr 확인 |
| `pg_restore: error: role "xxx" does not exist` | `--no-owner --role=DB_USERNAME`(스크립트 기본) 또는 `--globals`로 역할 먼저 복원 |
| 복원 후 앱 오류 | `pm2 restart`, `npm run db:schema`는 **복원 dump에 스키마 포함** 시 보통 불필요 |
### `data/` JSON과의 관계
`ENABLE_POSTGRES=1`이면 **강의·회의록·경영성과 등 핵심 데이터는 PostgreSQL**이 단일 소스입니다.
`data/ai-success-stories.json`, 업로드 파일(`uploads/`, `public/resources/`) 등 **파일 기반 데이터는 DB 백업에 포함되지 않습니다**. 운영 백업 정책에 파일 디렉터리 rsync/tar 백업을 **별도** 두는 것을 권장합니다.
---
- 인앱 채팅 화면은 `/ai-explore/chat`에서 제공하며, 백엔드는 `POST /api/chat/stream` SSE로 응답을 생성합니다.
- 기본 제공 모델 별칭은 `gpt-5.4`, `gpt-5-mini`이며, 실제 API 모델 ID는 `.env`의 `OPENAI_MODEL_DEFAULT`, `OPENAI_MODEL_MINI`로 매핑합니다.
- `OPENAI_WEB_SEARCH=1`(기본)일 때 OpenAI Responses API의 웹 검색 도구를 사용해 출처 링크를 함께 내려줍니다.
- 필수 환경 변수는 `OPENAI_API_KEY`입니다.
### PPT 썸네일 및 슬라이드 이미지 생성 제어(선택)
```bash
ENABLE_PPT_THUMBNAIL=1 npm start
```
- 기본값: `1` (활성)
- `ENABLE_PPT_THUMBNAIL=0`이면 썸네일·슬라이드 이미지 생성 비활성화
- 우선순위: macOS `qlmanage` → LibreOffice(`soffice/libreoffice`) + `pdftoppm`
- 도구 미설치 또는 변환 실패 시 텍스트 프리뷰로 자동 폴백
**PPT 슬라이드 이미지(뷰어용):** PPTX 파일은 LibreOffice로 PDF 변환 후 `pdftoppm`으로 이미지 생성.
- **PPTX**: LibreOffice 필수. macOS: `brew install --cask libreoffice`
- **PDF**: `pdftoppm`만 있으면 동작 (poppler 패키지)
- **한글 깨짐(이미지 안만 □)**: 서버에 PPT가 쓰는 글꼴·한글 폰트가 없을 때 발생. Linux: `fonts-nanum`, `fonts-noto-cjk` 등 설치 후 `fc-cache -fv`, 슬라이드 이미지 재생성.
### 업로드 동영상 카드 썸네일(선택)
- 기본값: `ENABLE_VIDEO_THUMBNAIL`이 `0`이 아니면 활성(미설정 시 켜짐).
- 서버 **PATH**에 `ffmpeg`가 있어야 합니다. macOS: `brew install ffmpeg`, Ubuntu: `sudo apt install -y ffmpeg`.
- 추출 시각은 `VIDEO_THUMB_SEEK_SEC`(초, 기본 `0.5`)로 조정할 수 있습니다. 영상 앞부분이 검은 화면이면 값을 키워 보세요.
- `ENABLE_VIDEO_THUMBNAIL=0`이면 업로드 직후 썸네일은 생성하지 않으며, 카드는 기존처럼 텍스트 폴백만 표시합니다.
### `.env` 예시
```env
PORT=8030
HOST=0.0.0.0
ADMIN_TOKEN=ncue-admin
PAGE_SIZE=8
ENABLE_POSTGRES=1
DB_HOST=your-db-host
DB_PORT=5432
DB_DATABASE=your_database
DB_USERNAME=your_user
DB_PASSWORD=your_password
ENABLE_PPT_THUMBNAIL=1
ENABLE_VIDEO_THUMBNAIL=1
# VIDEO_THUMB_SEEK_SEC=0.5
THUMBNAIL_WIDTH=1000
THUMBNAIL_MAX_RETRY=2
THUMBNAIL_RETRY_DELAY_MS=5000
THUMBNAIL_EVENT_KEEP=200
THUMBNAIL_EVENT_PAGE_SIZE=50
# 채팅 기능 (OpenAI API 키)
OPENAI_API_KEY=
# OPENAI_MODEL_DEFAULT=gpt-4o
# OPENAI_MODEL_MINI=gpt-4o-mini
# OPS 이메일(@ncue.net) 로그인 세션: `OPS_SESSION_TTL_DAYS` 미설정·0·never = 만기 없음(이전 기본 15일). N(양의 정수)이면 로그인일+N일(서울 달력) 후 만료.
# OPS_SESSION_TTL_DAYS=0
```
---
## 사용 방법
1. 메인 페이지에서 강의 등록
- **유튜브 강의 등록**: 제목 + 유튜브 링크 (+설명)
- **PowerPoint 강의 등록**: 제목 + `.pptx` 파일 (+설명)
- **동영상 파일 등록**: 제목 + 영상 파일 (`ffmpeg`로 카드 썸네일 생성)
- 두 등록 폼 모두 **태그(쉼표 구분)** 입력 가능
2. 하단 **등록된 강의** 카드에서 항목 클릭
3. 강의 상세 화면에서 시청
- 유튜브: 동영상 재생
- PPT: 슬라이드 이미지 확인(미생성 장은 이미지 없이 카드만 표시)
### 검색/필터
- 검색어, 타입, 태그를 조합해서 목록을 조회할 수 있습니다.
- 결과 목록은 페이지 단위로 분할되어 이동 가능합니다.
### 관리자 삭제
1. 화면의 관리자 모드에서 토큰 입력 후 활성화
2. 강의 카드의 `삭제` 버튼으로 즉시 삭제
3. PPT 강의 삭제 시 업로드 파일도 함께 제거 시도
### 썸네일 재생성(관리자)
- 관리자 모드에서 PPT·업로드 동영상 카드의 `썸네일 재생성` 버튼으로 수동 재생성 가능
- `실패 썸네일 일괄 재시도` 버튼으로 실패 건을 큐에 일괄 재등록 가능
- `이벤트 로그 페이지`에서 기간/유형/강의ID/사유 필터 조회 가능
- 이벤트 로그는 CSV 다운로드 지원
---
## 주요 라우트
### 메뉴별 화면
| 경로 | 설명 |
|------|------|
| `GET /` | 학습센터 뷰어 (강의 목록) |
| `GET /learning` | 학습센터 뷰어 (검색/필터/페이지네이션) |
| `GET /admin` | 관리자 대시보드 (강의·썸네일·AI 등) |
| `GET /chat` | 회사규정 NotebookLM 페이지로 리다이렉트 |
| `GET /wm` | WM NotebookLM 페이지로 리다이렉트 |
| `GET /ai-explore/chat` | OpenAI 연동 채팅 화면 |
| `GET /ai-explore/fscan` | AI 플랫폼 내 FSCAN 조사각 선정도우미 화면(내재화 iframe) |
| `GET /ai-explore` | AI 탐색 (회의록·체크리스트 등 카드; 프롬프트 라이브러리는 `/ai-explore/prompts` 또는 좌측 **프롬프트** 메뉴) |
| `GET /ai-explore/prompts` | 프롬프트 라이브러리 — 공식 템플릿(`config/company-prompts.json`)+팀 공유·좋아요(PostgreSQL `prompt_community_entries`·`prompt_likes`) · `?id=` 딥링크 |
| `GET /ai-cases` | AI 활용 사례 목록(검색·태그 필터) — **관리자 등록**(`ai-success-stories` 메타) + **일반 제출**(`ai_use_case_submissions`) 병합 |
| `GET /ai-cases/submit/:id` | 일반 제출 사례 상세(UUID) — **본인·관리자**에게 상단 **수정하기** 링크(`GET /ai-cases/compose?edit=<uuid>`) |
| `GET /ai-cases/:slug` | AI 활용 사례 상세(메타·슬러그) |
| `GET /ai-cases/write` | AI 활용 사례 관리(관리자 토큰 필요) |
| `POST/PUT/DELETE /api/ai-success-stories` | 사례 CRUD(관리자) · 본문은 `data/ai-success-stories/*.md` |
| `GET /ai-cases/compose?edit=:uuid` | 일반 제출 **수정** 화면(제목·4섹션·태그·첨부·썸네일) — 권한 없으면 403 |
| `POST /api/ai-use-case-submissions` | AI 활용 사례 **일반 제출(글쓰기)** — OPS 이메일 로그인·PostgreSQL, `ai_use_case_submissions` — 본문 4필드는 WYSIWYG HTML이며 `sanitize-html`로 안전한 태그만 저장, 글자 수는 **보이는 텍스트(태그 제외)** 합산 |
| `PUT /api/ai-use-case-submissions/:id` | 일반 제출 **갱신**(제출자 이메일과 로그인 이메일 일치 또는 관리자 모드). **썸네일**은 새 파일이면 교체, `removeThumbnail=1`이면 삭제(새 파일·삭제 둘 다 없으면 유지). **첨부**는 기존을 `removeAttachmentPaths`(JSON 경로 배열)로 뺀 뒤, 새로 업로드한 파일이 뒤에 **추가**(합계 최대 10개) |
| `GET /ax-apply` | AX 과제 신청 |
| `GET /lectures/:id` | 강의 상세 뷰어 (유튜브/PPT) |
**AI 활용 사례 목록이 줄어든 경우:** 카드 목록은 **`data/ai-success-stories.json`**만 본다. `git pull` 등으로 JSON만 예전 버전이 되면 `.md`·PDF는 서버에 남아 있어도 **1건만 보일 수 있다.** 서버에서 `data/ai-success-stories/*.md`는 있는데 JSON에 없으면 `node scripts/merge-orphan-ai-success-stories.js`로 고아 `.md`를 메타에 다시 붙인 뒤, 관리자 화면에서 **부서·저자·PDF 경로** 등을 점검한다. JSON·본문·`public/resources/ai-success/` PDF는 **배포 서버에서 백업**하거나 저장소에 포함하는 정책을 권장한다.
### API
- `POST /api/prompts/likes/toggle` (OPS·DEV 이메일)
JSON `{ "kind": "official" | "community", "id": "<json id or uuid>" }` — 좋아요 토글. `{ ok, liked, likeCount }`
- `POST /api/prompts/community` (OPS·DEV 이메일)
`multipart/form-data` — `title`, `body`, `description?`, `tag?`, `promptFiles`(0~5), `resultFiles`(0~5) · **작성자**는 요청한 OPS 로그인 이메일이 `prompt_community_entries.author_email`에 저장됨 · DB `prompt_attachments` / `result_sample_attachments` JSON · 파일당 20MB → `{ ok, id, createdAt, promptFiles, resultFiles }` · 라이브러리 카드에는 이메일의 `@` 앞부분(예: `spark_ai@ncue.net` → `spark_ai`)이 표시됨
- `DELETE /api/prompts/community/:id` (OPS·DEV 이메일) — 본인 글만 소프트 삭제
- `POST /api/prompts/polish-workflow` (OPS·DEV 이메일)
JSON `{ draft }` — `OPENAI_API_KEY`로 워크플로 초안을 다듬은 프롬프트 문자열 `{ text }` 반환
- `GET /api/chat/config`
채팅용 OpenAI 키 설정 여부 `{ configured: boolean }` (키 문자열은 노출하지 않음)
- `POST /api/chat`
JSON `{ model, messages }` — OpenAI Chat Completions 연동(비스트리밍). `OPENAI_API_KEY` 필수.
- `POST /api/chat/stream`
동일 본문으로 **SSE**(`text/event-stream`) 스트리밍 응답. 이벤트: `data: {"type":"delta","text":"..."}` 조각, 마지막 `{"type":"done"}`. 오류 시 `{"type":"error","error":"..."}`. 채팅 UI는 이 엔드포인트를 사용합니다.
- `POST /lectures/youtube`
유튜브 강의 등록
- `POST /lectures/ppt`
PPT 강의 등록 (`multipart/form-data`)
- `POST /lectures/:id/delete`
관리자 토큰 기반 강의 삭제
- `POST /lectures/:id/thumbnail/regenerate`
관리자 토큰 기반 PDF/PPT·업로드 동영상 썸네일 재생성
- `POST /thumbnails/retry-failed`
관리자 토큰 기반 실패 썸네일 일괄 재시도 큐 등록
- `GET /api/queue/metrics?token=...`
관리자 토큰 기반 큐/상태 메트릭 JSON 조회
- `GET /api/queue/events-summary?token=...&hours=24`
최근 시간대 요약 KPI/시간대별 처리량 JSON 조회
- `GET /admin/thumbnail-events?token=...`
관리자 이벤트 로그 페이지 (필터/페이지네이션)
- `GET /admin/thumbnail-events.csv?token=...`
필터 기준 이벤트 로그 CSV 다운로드
---
## 최근 업데이트 (요약)
- **PostgreSQL 백업·복원**: `scripts/pg-backup.sh` / `scripts/pg-restore.sh`, `npm run db:backup` · README 「PostgreSQL 백업 및 복원」에 cron·복원 절차 문서화
- **채팅** (`/ai-explore/chat`): OpenAI SSE 기반 인앱 채팅으로 응답/출처를 표시
- **AI 탐색 필터** (`/ai-explore`): 검색창 아래 타입 라디오 필터(일반/XScan/FScan)로 카드 목록 필터링
- **FSCAN 도구 내재화**: `public/resources/fscan/fscan-selector-v1.html` 정적 페이지를 `/ai-explore/fscan`에서 iframe으로 감싸 AI 플랫폼 레이아웃(좌측 메뉴/뒤로 이동) 안에서 사용
- **AI 탐색** (`/ai-explore`): 메인 콘텐츠를 뷰포트 전체 너비로 사용(회의록·체크리스트 등). **프롬프트 라이브러리**는 AI 목록이 아닌 좌측 **프롬프트** 메뉴(`/ai-explore/prompts`)로 진입. **좌측 전체 메뉴는 관리자 여부와 관계없이 동일하게 표시·접근 가능**
- **프롬프트 라이브러리** (`/ai-explore/prompts`): 시나리오 카드·미리보기·복사 UI, 템플릿 데이터는 `config/company-prompts.json`에서 로드
- **서버 수신 주소**: 기본 `HOST=0.0.0.0`(로컬 `localhost` 접속에 사용). 필요 시 `HOST=127.0.0.1`로 제한 가능
- **문서**: `localhost` 접속 불가 시 확인할 항목을 아래 「접속이 안 될 때 (트러블슈팅)」 절에 정리
---
## 기술 스택
- Node.js
- Express
- EJS
- PostgreSQL (`pg`)
- Multer (파일 업로드)
- JSZip (`.pptx` 내부 XML 파싱)
- UUID
---
## 구현 참고/제약
- PPT 뷰어는 **원본 슬라이드 디자인 렌더링**이 아닌, `.pptx` 내부 텍스트를 추출해 보여주는 방식입니다.
- PPT·동영상 썸네일은 시스템 도구(`qlmanage`/LibreOffice/`ffmpeg` 등) 상태에 따라 생성 실패할 수 있으며, 이 경우 이미지 없이 텍스트 프리뷰만 표시됩니다.
- 썸네일 큐는 단일 프로세스 워커 기준으로 동작합니다(다중 인스턴스 분산 락은 미구현).
- 폰트/도형/애니메이션까지 완전 동일 렌더링이 필요하면 별도 문서 렌더러(예: LibreOffice/PDF 변환 파이프라인) 연동이 필요합니다.
- 이벤트 로그 페이지의 실시간 그래프는 클라이언트 폴링 기반이며, 다수 접속 시 폴링 주기 조정이 필요할 수 있습니다.