# 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 변환 파이프라인) 연동이 필요합니다.
- 이벤트 로그 페이지의 실시간 그래프는 클라이언트 폴링 기반이며, 다수 접속 시 폴링 주기 조정이 필요할 수 있습니다.

