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>
This commit is contained in:
dsyoon
2026-05-26 22:27:48 +09:00
parent 7bee72f287
commit 073a8343dd
84 changed files with 10883 additions and 1043 deletions

View File

@@ -0,0 +1,429 @@
#!/usr/bin/env python3
"""
AI Platform 메뉴 안내 PPT — 일반 임직원(관리자·특수 메뉴 제외)
- 가이드봇·WM·대시보드·업무 체크리스트: 허용 계정만 (본 PPT 범위 외)
"""
from __future__ import annotations
from pathlib import Path
from pptx import Presentation
from pptx.dml.color import RGBColor
from pptx.enum.shapes import MSO_SHAPE
from pptx.enum.text import MSO_ANCHOR, PP_ALIGN
from pptx.util import Inches, Pt
ROOT = Path(__file__).resolve().parent.parent
SHOT_DIR = ROOT / "docs" / "ppt-screenshots-user"
OUT_PPT = ROOT / "docs" / "XAVIS-AI-Platform-메뉴안내-일반사용자.pptx"
# Brand palette
NAVY = RGBColor(0x0F, 0x17, 0x2A)
BLUE = RGBColor(0x25, 0x63, 0xEB)
BLUE_LIGHT = RGBColor(0xDB, 0xEA, 0xFE)
SLATE = RGBColor(0x47, 0x55, 0x69)
SLATE_LIGHT = RGBColor(0x94, 0xA3, 0xB8)
WHITE = RGBColor(0xFF, 0xFF, 0xFF)
BG = RGBColor(0xF8, 0xFA, 0xFC)
SLIDE_W = Inches(13.333)
SLIDE_H = Inches(7.5)
SLIDES: list[tuple[str, str, list[str], str | None]] = [
(
"cover",
"XAVIS AI Platform",
[
"일반 사용자 이용 가이드",
"https://ai.xavis.co.kr/",
"@ncue.net 이메일 인증 후 이용 · 관리자 모드 불필요",
],
None,
),
(
"section",
"시작하기",
[],
None,
),
(
"content",
"이메일 인증으로 접속",
[
"1. ai.xavis.co.kr 접속 → 회사 이메일 입력 → [검증]",
"2. 메일함 [인증 완료하기] 클릭 (링크 15분 유효)",
"3. 인증 완료 후 AI·학습센터 등 메뉴 이용",
"로그아웃: 좌측 하단 · 좌측 [관리자]는 운영 담당용(일반 사용자 불필요)",
],
"login.png",
),
(
"content",
"일반 사용자 메뉴 구성",
[
"AI — 회의록·채팅·FSCAN 등 AI 서비스",
"프롬프트 — 업무용 템플릿·팀 공유",
"학습센터 — YouTube·PPT·동영상 강의",
"과제신청 — AX 과제 온라인 신청",
"AI 활용 사례 — 임직원 간 실무 AI 활용 노하우 공유·열람",
"※ 가이드봇·WM·대시보드·업무 체크리스트는 허용 계정만 표시",
],
"menu-overview.png",
),
(
"section",
"AI 서비스",
[],
None,
),
(
"content",
"AI — 서비스 허브",
[
"경로: /ai-explore",
"검색 + 타입 필터(전체 / 일반 / XScan / FScan)",
"카드 클릭으로 각 AI 도구 실행",
],
"ai-explore.png",
),
(
"content",
"회의록 AI",
[
"텍스트: 회의 원문 붙여넣기 → [회의록 생성] → 수정 후 [저장]",
"음성: mp3·m4a·wav(최대 300MB) → 업로드 → 전사 → 회의록 정리",
"대안: 클로버노트·Claude 전사 → 텍스트 입력 탭에 붙여넣기",
"유의: 결과 검토 필수 · 개인정보·대외비 주의",
],
"meeting-minutes.png",
),
(
"content",
"일반 채팅",
[
"이메일 인증 후 ChatGPT 기반 사내 채팅",
"업무 질의·초안·아이디어 · (설정 시) 웹 검색",
"반복 업무는 [프롬프트] 템플릿 복사 후 활용",
],
"chat.png",
),
(
"content",
"FSCAN 조사각 선정도우미",
[
"검사물 H/W 치수 입력 → FSCAN 모델 1차 선정",
"영업·기술 검토용 · 최종 스펙은 공식 카탈로그·기술팀 확인",
],
"fscan.png",
),
(
"section",
"업무 지원",
[],
None,
),
(
"content",
"프롬프트 라이브러리",
[
"공식 템플릿(회의·메일·보고·OKR 등) 미리보기 → 복사",
"워크플로: 4단계 입력으로 맞춤 지시문 초안",
"공유하기: 팀 프롬프트 등록(로그인 필요, 기밀 제외)",
],
"prompts.png",
),
(
"content",
"학습센터",
[
"YouTube · PPT/PDF · 동영상 · 웹 링크 강의 검색·시청",
"카테고리: AX 사고 전환 · AI 툴 활용 · AI Agent · 바이브 코딩",
"캡처: 등록 강의 목록 전체(무한 스크롤 로드 후)",
"강의 등록·수정은 운영 담당 — 일반 사용자는 시청·검색",
],
"learning.png",
),
(
"content",
"AX 과제 신청",
[
"Word 양식 다운로드 + 온라인 신청서 작성",
"본인 신청 조회·수정(부서·이름·이메일)",
"유사 사례는 AI 활용 사례 메뉴 참고",
],
"ax-apply.png",
),
(
"content",
"AI 활용 사례 — 공유 공간과 열람",
[
"각자의 실무 AI 활용법을 전사 임직원과 공유하는 공간(학습센터=교육, 여기=현장 검증 사례)",
"로그인 임직원 누구나 열람·글쓰기 · 부서·태그·검색으로 유사 사례 탐색",
"AX 과제·새 업무 전 참고 · 카드 클릭 → STAR 상세·재현 방법 확인",
"경로: 좌측 [AI 활용 사례] · Before/After·활용 도구·성과 중심",
],
"ai-cases.png",
),
(
"content",
"AI 활용 사례 — 나의 경험 공유하기",
[
"[글쓰기] → STAR 본문(배경→과제→AI 활용→성과) · 활용 AI 태그 · 썸네일",
"동료가 내일 바로 따라 할 수 있도록 도구·절차·수치를 구체적으로(과장 금지)",
"※ 개인정보·고객·기밀 미포함 · AI 결과는 본인 검증 후 · 문의 AI혁신팀",
],
"ai-cases-compose.png",
),
(
"section",
"정리",
[],
None,
),
(
"content",
"업무별 Quick Reference",
[
"음성 회의록 → AI → 회의록 AI (또는 클로버노트 → 텍스트 입력)",
"빠른 질문 → 일반 채팅 · 메일·보고 초안 → 프롬프트",
"학습 → 학습센터 · AX 과제 → 과제신청 · 사례 → AI 활용 사례",
"문의: AI혁신팀",
],
None,
),
(
"closing",
"감사합니다",
[
"XAVIS AI Platform",
"https://ai.xavis.co.kr/",
"AI혁신팀",
],
None,
),
]
def _set_solid_fill(shape, color: RGBColor) -> None:
shape.fill.solid()
shape.fill.fore_color.rgb = color
def _add_header_bar(slide, title: str) -> None:
bar = slide.shapes.add_shape(MSO_SHAPE.RECTANGLE, Inches(0), Inches(0), SLIDE_W, Inches(0.95))
_set_solid_fill(bar, NAVY)
bar.line.fill.background()
accent = slide.shapes.add_shape(MSO_SHAPE.RECTANGLE, Inches(0), Inches(0.95), SLIDE_W, Inches(0.06))
_set_solid_fill(accent, BLUE)
accent.line.fill.background()
tb = slide.shapes.add_textbox(Inches(0.55), Inches(0.18), Inches(10.5), Inches(0.55))
tf = tb.text_frame
tf.text = title
p = tf.paragraphs[0]
p.font.name = "Apple SD Gothic Neo"
p.font.size = Pt(24)
p.font.bold = True
p.font.color.rgb = WHITE
def _add_footer(slide, page_num: int) -> None:
line = slide.shapes.add_shape(MSO_SHAPE.RECTANGLE, Inches(0.55), Inches(7.05), Inches(12.2), Inches(0.015))
_set_solid_fill(line, BLUE_LIGHT)
line.line.fill.background()
left = slide.shapes.add_textbox(Inches(0.55), Inches(7.12), Inches(5), Inches(0.28))
ltf = left.text_frame
ltf.text = "XAVIS AI Platform · 일반 사용자 가이드"
lp = ltf.paragraphs[0]
lp.font.size = Pt(9)
lp.font.color.rgb = SLATE_LIGHT
right = slide.shapes.add_textbox(Inches(11.8), Inches(7.12), Inches(1.0), Inches(0.28))
rtf = right.text_frame
rtf.text = str(page_num)
rp = rtf.paragraphs[0]
rp.font.size = Pt(9)
rp.font.color.rgb = SLATE_LIGHT
rp.alignment = PP_ALIGN.RIGHT
def _add_bullets(slide, bullets: list[str], left: float, top: float, width: float, height: float, font_size: int = 14) -> None:
box = slide.shapes.add_textbox(Inches(left), Inches(top), Inches(width), Inches(height))
tf = box.text_frame
tf.word_wrap = True
for i, line in enumerate(bullets):
para = tf.paragraphs[0] if i == 0 else tf.add_paragraph()
para.text = line
para.font.name = "Apple SD Gothic Neo"
para.font.size = Pt(font_size)
para.font.color.rgb = SLATE
para.space_after = Pt(8)
para.level = 0
if not line.startswith(""):
para.text = f"{line}"
def _add_screenshot(slide, shot_name: str, left: float, top: float, width: float) -> bool:
path = SHOT_DIR / shot_name
if not path.is_file():
return False
pic = slide.shapes.add_picture(str(path), Inches(left), Inches(top), width=Inches(width))
max_height = Inches(5.85)
if pic.height > max_height:
scale = max_height / pic.height
pic.width = int(pic.width * scale)
pic.height = int(pic.height * scale)
frame = slide.shapes.add_shape(
MSO_SHAPE.RECTANGLE,
pic.left - Inches(0.04),
pic.top - Inches(0.04),
pic.width + Inches(0.08),
pic.height + Inches(0.08),
)
frame.fill.background()
frame.line.color.rgb = BLUE_LIGHT
frame.line.width = Pt(1.25)
# picture on top
slide.shapes._spTree.remove(pic._element)
slide.shapes._spTree.insert(-1, pic._element)
return True
def add_cover_slide(prs: Presentation, title: str, bullets: list[str]) -> None:
slide = prs.slides.add_slide(prs.slide_layouts[6])
bg = slide.shapes.add_shape(MSO_SHAPE.RECTANGLE, Inches(0), Inches(0), SLIDE_W, SLIDE_H)
_set_solid_fill(bg, NAVY)
bg.line.fill.background()
stripe = slide.shapes.add_shape(MSO_SHAPE.RECTANGLE, Inches(0), Inches(2.8), SLIDE_W, Inches(0.08))
_set_solid_fill(stripe, BLUE)
stripe.line.fill.background()
tb = slide.shapes.add_textbox(Inches(0.9), Inches(1.5), Inches(11.5), Inches(1.0))
tf = tb.text_frame
tf.text = title
p = tf.paragraphs[0]
p.font.size = Pt(44)
p.font.bold = True
p.font.color.rgb = WHITE
sub = slide.shapes.add_textbox(Inches(0.9), Inches(3.2), Inches(11.0), Inches(2.5))
stf = sub.text_frame
for i, line in enumerate(bullets):
para = stf.paragraphs[0] if i == 0 else stf.add_paragraph()
para.text = line
para.font.size = Pt(20 if i == 0 else 16)
para.font.bold = i == 0
para.font.color.rgb = BLUE_LIGHT if i == 0 else SLATE_LIGHT
para.space_after = Pt(10)
def add_section_slide(prs: Presentation, title: str, page_num: int) -> None:
slide = prs.slides.add_slide(prs.slide_layouts[6])
bg = slide.shapes.add_shape(MSO_SHAPE.RECTANGLE, Inches(0), Inches(0), SLIDE_W, SLIDE_H)
_set_solid_fill(bg, BLUE)
bg.line.fill.background()
tb = slide.shapes.add_textbox(Inches(0.9), Inches(3.0), Inches(11), Inches(1.2))
tf = tb.text_frame
tf.text = title
p = tf.paragraphs[0]
p.font.size = Pt(36)
p.font.bold = True
p.font.color.rgb = WHITE
_add_footer(slide, page_num)
def add_text_slide(prs: Presentation, title: str, bullets: list[str], page_num: int) -> None:
"""스크린샷 없이 본문만 — 설명·가이드용"""
slide = prs.slides.add_slide(prs.slide_layouts[6])
bg = slide.shapes.add_shape(MSO_SHAPE.RECTANGLE, Inches(0), Inches(0.95), SLIDE_W, Inches(6.55))
_set_solid_fill(bg, BG)
bg.line.fill.background()
_add_header_bar(slide, title)
highlight = slide.shapes.add_shape(MSO_SHAPE.RECTANGLE, Inches(0.55), Inches(1.15), Inches(0.08), Inches(5.6))
_set_solid_fill(highlight, BLUE)
highlight.line.fill.background()
_add_bullets(slide, bullets, 0.75, 1.25, 11.8, 5.5, font_size=15)
_add_footer(slide, page_num)
def add_content_slide(prs: Presentation, title: str, bullets: list[str], shot_name: str | None, page_num: int) -> None:
slide = prs.slides.add_slide(prs.slide_layouts[6])
bg = slide.shapes.add_shape(MSO_SHAPE.RECTANGLE, Inches(0), Inches(0.95), SLIDE_W, Inches(6.55))
_set_solid_fill(bg, BG)
bg.line.fill.background()
_add_header_bar(slide, title)
has_shot = shot_name and (SHOT_DIR / shot_name).is_file()
text_w = 5.6 if has_shot else 12.0
fs = 13 if len(bullets) >= 5 else 14
_add_bullets(slide, bullets, 0.55, 1.25, text_w, 5.5, font_size=fs)
if has_shot and shot_name:
_add_screenshot(slide, shot_name, 6.45, 1.15, 6.35)
_add_footer(slide, page_num)
def add_closing_slide(prs: Presentation, title: str, bullets: list[str], page_num: int) -> None:
slide = prs.slides.add_slide(prs.slide_layouts[6])
bg = slide.shapes.add_shape(MSO_SHAPE.RECTANGLE, Inches(0), Inches(0), SLIDE_W, SLIDE_H)
_set_solid_fill(bg, NAVY)
bg.line.fill.background()
tb = slide.shapes.add_textbox(Inches(0.9), Inches(2.6), Inches(11.5), Inches(1.0))
tf = tb.text_frame
tf.text = title
p = tf.paragraphs[0]
p.font.size = Pt(40)
p.font.bold = True
p.font.color.rgb = WHITE
p.alignment = PP_ALIGN.CENTER
sub = slide.shapes.add_textbox(Inches(0.9), Inches(3.8), Inches(11.5), Inches(1.5))
stf = sub.text_frame
for i, line in enumerate(bullets):
para = stf.paragraphs[0] if i == 0 else stf.add_paragraph()
para.text = line
para.font.size = Pt(18)
para.font.color.rgb = SLATE_LIGHT
para.alignment = PP_ALIGN.CENTER
para.space_after = Pt(6)
_add_footer(slide, page_num)
def build() -> Path:
prs = Presentation()
prs.slide_width = SLIDE_W
prs.slide_height = SLIDE_H
page = 0
for kind, title, bullets, shot in SLIDES:
page += 1
if kind == "cover":
add_cover_slide(prs, title, bullets)
elif kind == "section":
add_section_slide(prs, title, page)
elif kind == "closing":
add_closing_slide(prs, title, bullets, page)
elif kind == "text":
add_text_slide(prs, title, bullets, page)
else:
add_content_slide(prs, title, bullets, shot, page)
OUT_PPT.parent.mkdir(parents=True, exist_ok=True)
prs.save(str(OUT_PPT))
return OUT_PPT
if __name__ == "__main__":
out = build()
print(f"PPT saved: {out}")

View File

@@ -0,0 +1,285 @@
#!/usr/bin/env python3
"""
AI Platform 메뉴 안내 PPT 생성 (python-pptx)
스크린샷: docs/ppt-screenshots/*.png
"""
from __future__ import annotations
import os
from pathlib import Path
from pptx import Presentation
from pptx.dml.color import RGBColor
from pptx.enum.text import MSO_ANCHOR, PP_ALIGN
from pptx.util import Inches, Pt
ROOT = Path(__file__).resolve().parent.parent
SHOT_DIR = ROOT / "docs" / "ppt-screenshots"
OUT_PPT = ROOT / "docs" / "XAVIS-AI-Platform-메뉴안내.pptx"
# 슬라이드 정의: (제목, 불릿 목록, 스크린샷 파일명 또는 None)
SLIDES: list[tuple[str, list[str], str | None]] = [
(
"XAVIS AI Platform 메뉴 안내",
[
"사내 AI 도구·학습·과제·활용 사례 통합 포털",
"접속: https://ai.xavis.co.kr/",
"최초 1회: @ncue.net 이메일 → 인증 메일 링크(15분 유효)",
"좌측 메뉴: 회사규정 · WM · AI · 프롬프트 · 학습센터 · 과제신청 · AI 활용 사례 · 대시보드(허용자)",
],
None,
),
(
"1. 서비스 접속 (로그인)",
[
"경로: /login — 미인증 시 자동 이동",
"회사 이메일 입력 → [검증] → 메일 [인증 완료하기] 클릭",
"인증 후 학습센터 등 원하는 화면으로 이동",
"유의: @ncue.net 만 가능 · 링크 만료 시 재검증 · 로그아웃은 좌측 하단",
],
"login.png",
),
(
"2. 회사규정",
[
"좌측 [회사규정] 클릭 → Google NotebookLM (새 탭, 회사 Google 계정 로그인)",
"사내 규정·지침 AI 검색·요약·질의응답",
"캡처: 좌측 메뉴 [회사규정] 위치 (학습센터 화면)",
"AI 답변은 원문 규정·다우오피스 공식 문서와 반드시 대조",
],
"company-policy.png",
),
(
"3. WM",
[
"좌측 [WM] 클릭 → NotebookLM WM 노트북 (새 탭)",
"WM 프로세스·용어·가이드 질의",
"캡처: 좌측 메뉴 [WM] 위치 (AI 화면)",
"공식 매뉴얼·담당 부서 확인 병행",
],
"wm.png",
),
(
"4. AI (메인 허브)",
[
"경로: /ai-explore — AI 서비스 카드 허브",
"검색 + 타입 필터(전체/일반/XScan/FScan)",
"회의록 AI · 업무 체크리스트 · 일반 채팅 · FSCAN 선정도우미",
],
"ai-explore.png",
),
(
"5. 회의록 AI",
[
"텍스트 입력: 회의 원문 붙여넣기 → 회의록 생성 → 저장",
"음성 파일: mp3·m4a·wav(최대 300MB) → 업로드→전사→회의록 정리",
"저장 시 업무 체크리스트 AI와 자동 연동",
"대안: Claude 음성 업로드 / 클로버노트 전사 → 텍스트 입력 탭 붙여넣기",
"유의: 생성 결과 검토 필수 · 개인정보·대외비 주의",
],
"meeting-minutes.png",
),
(
"6. 업무 체크리스트 AI",
[
"회의록 AI 저장분의 액션·체크리스트 통합 관리",
"진행/완료 필터 · 회의별 필터 · 완료 처리 메모",
"흐름: 회의록 AI 저장 → 체크리스트에서 추적",
],
"task-checklist.png",
),
(
"7. 일반 채팅",
[
"ChatGPT 기반 사내 인앱 채팅 (로그인 필요)",
"업무 질의·초안·아이디어 · (설정 시) 웹 검색",
"반복 프롬프트는 [프롬프트] 메뉴 템플릿 활용",
"유의: AI 답변은 실수할 수 있음 · 기밀 입력 자제",
],
"chat.png",
),
(
"8. FSCAN 조사각 선정도우미",
[
"검사 대상물 H/W 치수 입력 → FSCAN 모델 1차 선정",
"영업·기술 검토 시 빠른 선정용",
"최종 스펙은 공식 카탈로그·기술팀 확인 필수",
],
"fscan.png",
),
(
"9. 프롬프트 라이브러리",
[
"공식 템플릿: 회의 요약·이메일·보고·OKR·코드리뷰 등 → 복사",
"워크플로: 4단계 입력 → 맞춤 프롬프트 초안",
"공유하기: 팀 프롬프트·참고 파일 (기밀 제외)",
],
"prompts.png",
),
(
"10. 학습센터",
[
"YouTube · PPT/PDF · 동영상 · 웹·뉴스 링크 강의",
"카테고리: AX 사고 전환 · AI 툴 활용 · AI Agent · 바이브 코딩",
"검색 예: claude, 클로드 → 도구별 강의 찾기",
],
"learning.png",
),
(
"11. AX 과제 신청",
[
"온라인 신청 + Word 양식 다운로드",
"Pain Point · AI 기대 · 데이터·효과 등 작성",
"조회로 본인 신청 확인·수정 · AI 활용 사례 참고",
],
"ax-apply.png",
),
(
"12. AI 활용 사례",
[
"부서별 도입·성과 사례 카드 열람",
"글쓰기: STAR 형식(1.Situation~4.Result)",
"AX 과제·팀 공유 레퍼런스",
],
"ai-cases.png",
),
(
"13. 대시보드 (허용 계정)",
[
"허용 이메일만 좌측 메뉴 표시",
"경영성과: 연도·분기 KPI 차트 + 매출일보 엑셀 업로드",
],
"dashboard.png",
),
(
"14. 경영성과 대시보드",
[
"상단: Chart.js KPI·차트 조회",
"하단: .xlsx 매출일보 업로드 → 스냅샷 저장",
],
"dashboard-business-performance.png",
),
(
"업무별 Quick Reference",
[
"음성 회의록 → AI → 회의록 AI (또는 클로버노트 → 텍스트 입력)",
"할 일 추적 → 업무 체크리스트 AI",
"빠른 질문 → 일반 채팅 · 메일·보고 → 프롬프트",
"규정 → 회사규정 · 학습 → 학습센터 · AI 과제 → 과제신청",
"문의: AI혁신팀",
],
None,
),
]
ACCENT = RGBColor(0x25, 0x63, 0xEB)
DARK = RGBColor(0x11, 0x18, 0x27)
GRAY = RGBColor(0x4B, 0x55, 0x63)
def add_title_slide(prs: Presentation, title: str, bullets: list[str]) -> None:
layout = prs.slide_layouts[6] # blank
slide = prs.slides.add_slide(layout)
slide.background.fill.solid()
slide.background.fill.fore_color.rgb = RGBColor(0xF0, 0xF4, 0xFF)
box = slide.shapes.add_textbox(Inches(0.8), Inches(2.2), Inches(11.5), Inches(1.2))
tf = box.text_frame
tf.text = title
p = tf.paragraphs[0]
p.font.size = Pt(40)
p.font.bold = True
p.font.color.rgb = DARK
p.alignment = PP_ALIGN.CENTER
sub = slide.shapes.add_textbox(Inches(1.2), Inches(3.6), Inches(10.8), Inches(2.5))
stf = sub.text_frame
stf.word_wrap = True
for i, line in enumerate(bullets[:4]):
para = stf.paragraphs[0] if i == 0 else stf.add_paragraph()
para.text = line
para.font.size = Pt(18)
para.font.color.rgb = GRAY
para.space_after = Pt(8)
para.alignment = PP_ALIGN.CENTER
def add_content_slide(
prs: Presentation,
title: str,
bullets: list[str],
shot_name: str | None,
) -> None:
layout = prs.slide_layouts[6]
slide = prs.slides.add_slide(layout)
# 제목
title_box = slide.shapes.add_textbox(Inches(0.5), Inches(0.25), Inches(12.3), Inches(0.6))
ttf = title_box.text_frame
ttf.text = title
tp = ttf.paragraphs[0]
tp.font.size = Pt(26)
tp.font.bold = True
tp.font.color.rgb = ACCENT
has_shot = shot_name and (SHOT_DIR / shot_name).is_file()
text_left = Inches(0.5)
text_top = Inches(0.95)
text_w = Inches(5.8) if has_shot else Inches(12.3)
text_h = Inches(6.2)
body = slide.shapes.add_textbox(text_left, text_top, text_w, text_h)
btf = body.text_frame
btf.word_wrap = True
for i, line in enumerate(bullets):
para = btf.paragraphs[0] if i == 0 else btf.add_paragraph()
para.text = f"{line}"
para.font.size = Pt(14)
para.font.color.rgb = DARK
para.space_after = Pt(6)
para.level = 0
if has_shot:
shot_path = str(SHOT_DIR / shot_name)
slide.shapes.add_picture(
shot_path,
Inches(6.6),
Inches(0.85),
width=Inches(6.2),
)
elif shot_name:
note = slide.shapes.add_textbox(Inches(6.6), Inches(2.5), Inches(6.0), Inches(1.0))
ntf = note.text_frame
ntf.text = f"(캡처 없음: {shot_name})"
ntf.paragraphs[0].font.size = Pt(12)
ntf.paragraphs[0].font.color.rgb = GRAY
# 슬라이드 번호
num = len(prs.slides)
footer = slide.shapes.add_textbox(Inches(12.0), Inches(7.05), Inches(0.8), Inches(0.3))
ftf = footer.text_frame
ftf.text = str(num)
ftf.paragraphs[0].font.size = Pt(10)
ftf.paragraphs[0].font.color.rgb = GRAY
ftf.paragraphs[0].alignment = PP_ALIGN.RIGHT
def build() -> Path:
prs = Presentation()
prs.slide_width = Inches(13.333)
prs.slide_height = Inches(7.5)
for idx, (title, bullets, shot) in enumerate(SLIDES):
if idx == 0:
add_title_slide(prs, title, bullets)
else:
add_content_slide(prs, title, bullets, shot)
OUT_PPT.parent.mkdir(parents=True, exist_ok=True)
prs.save(str(OUT_PPT))
return OUT_PPT
if __name__ == "__main__":
out = build()
print(f"PPT saved: {out}")

View File

@@ -0,0 +1,293 @@
/**
* AI Platform 메뉴 화면 캡처 (Playwright)
* - 로그인: 운영 URL (PROD 전용 화면)
* - 나머지: 로컬 SUPER 모드 서버 (인증 없이 캡처)
* - 대시보드: OPS 세션 쿠키(허용 이메일) 주입 후 캡처
*/
import { chromium } from "playwright";
import crypto from "crypto";
import fs from "fs/promises";
import fsSync from "fs";
import path from "path";
import { fileURLToPath } from "url";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const OUT_DIR =
process.env.CAPTURE_OUT_DIR ||
path.join(__dirname, "..", "docs", "ppt-screenshots");
const LOCAL_BASE = process.env.CAPTURE_BASE_URL || "http://127.0.0.1:8030";
const PROD_BASE = (process.env.CAPTURE_PROD_BASE || "https://ai.xavis.co.kr").replace(/\/$/, "");
const PROD_LOGIN = `${PROD_BASE}/login`;
/** 목록(학습·사례)은 운영 데이터·전체 로드 후 캡처 */
const CAPTURE_LISTS_FROM_PROD = process.env.CAPTURE_LISTS_FROM_PROD !== "0";
/** 일반 사용자 OPS 세션 (관리자·특수 메뉴 쿠키 없음) */
const CAPTURE_USER_EMAIL = (
process.env.CAPTURE_USER_EMAIL || "employee@ncue.net"
)
.trim()
.toLowerCase();
/** general: 가이드봇·WM·대시보드·체크리스트 제외 캡처 */
const CAPTURE_PROFILE = (process.env.CAPTURE_PROFILE || "general").trim().toLowerCase();
const LOCAL_PAGES_ALL = [
{ id: "ai-explore", path: "/ai-explore", waitMs: 800 },
{ id: "meeting-minutes", path: "/ai-explore/meeting-minutes", waitMs: 1200 },
{ id: "task-checklist", path: "/ai-explore/task-checklist", waitMs: 1200 },
{ id: "chat", path: "/ai-explore/chat", waitMs: 800 },
{ id: "fscan", path: "/ai-explore/fscan", waitMs: 2000 },
{ id: "prompts", path: "/ai-explore/prompts", waitMs: 1500 },
{ id: "ax-apply", path: "/ax-apply", waitMs: 1000 },
];
/** 목록 패널 전체(element) 캡처 — viewport 잘림 방지 */
const LIST_PANEL_PAGES = [
{
id: "learning",
path: "/learning",
selector: "section.panel:has(#lecture-results-root)",
useProd: true,
preload: "learning",
},
{
id: "ai-cases",
path: "/ai-cases",
selector: "section.panel:has(.success-story-grid)",
useProd: true,
preload: "none",
},
{
id: "ai-cases-compose",
path: "/ai-cases/compose",
selector: "main.use-case-compose",
useProd: true,
preload: "none",
},
];
const LOCAL_PAGES =
CAPTURE_PROFILE === "general"
? LOCAL_PAGES_ALL.filter(
(p) => !["task-checklist", "dashboard", "dashboard-business-performance"].includes(p.id)
)
: LOCAL_PAGES_ALL;
/** 일반 사용자 좌측 메뉴(가이드봇·WM·대시보드 없음) */
const MENU_OVERVIEW = { id: "menu-overview", path: "/ai-explore", waitMs: 1000 };
function readEnvValue(key) {
const envPath = path.join(__dirname, "..", ".env");
try {
const raw = fsSync.readFileSync(envPath, "utf8");
const re = new RegExp(`^${key}=(.+)$`, "m");
const m = raw.match(re);
if (!m) return "";
return m[1].split("#")[0].trim().replace(/^["']|["']$/g, "");
} catch {
return "";
}
}
/** ops-auth.js 와 동일한 세션 쿠키 서명 */
function buildOpsSessionCookie(email) {
const secret = readEnvValue("AUTH_SECRET") || readEnvValue("ADMIN_TOKEN") || "ncue-admin";
const exp = Number.MAX_SAFE_INTEGER;
const iat = Date.now();
const payload = `${email}|${exp}|${iat}`;
const sig = crypto.createHmac("sha256", secret).update(payload).digest("hex");
return Buffer.from(JSON.stringify({ email, exp, iat, sig })).toString("base64url");
}
function captureUserEmail() {
return CAPTURE_USER_EMAIL;
}
async function capturePage(page, url, outPath, waitMs = 1000, fullPage = false) {
await page.goto(url, { waitUntil: "networkidle", timeout: 90000 });
await page.waitForTimeout(waitMs);
await page.screenshot({ path: outPath, fullPage });
console.log(" saved:", outPath);
}
/** 학습센터 무한 스크롤 — 모든 페이지 로드 */
async function loadAllLearningPages(page) {
await page.waitForSelector("#lecture-grid", { timeout: 60000 });
let guard = 0;
while (guard < 80) {
guard += 1;
const state = await page.evaluate(() => {
const sentinel = document.getElementById("infinite-scroll-sentinel");
const btn = document.getElementById("lecture-load-more-btn");
const countEl = document.getElementById("lecture-total-count");
const cards = document.querySelectorAll("#lecture-grid .lecture-card, #lecture-grid a.lecture-card");
return {
hasNext: !!(sentinel && sentinel.getAttribute("data-has-next") === "true"),
hasBtn: !!btn,
total: countEl ? countEl.textContent.trim() : "",
loaded: cards.length,
};
});
if (!state.hasNext) break;
if (state.hasBtn) {
await page.locator("#lecture-load-more-btn").click({ timeout: 5000 }).catch(() => {});
} else {
await page.evaluate(() => {
const s = document.getElementById("infinite-scroll-sentinel");
if (s) s.scrollIntoView({ block: "end", behavior: "instant" });
window.scrollTo(0, document.body.scrollHeight);
});
}
await page.waitForTimeout(900);
await page
.waitForFunction(
() => {
const el = document.getElementById("infinite-scroll-loading");
return !el || el.style.display === "none" || el.style.display === "";
},
{ timeout: 45000 }
)
.catch(() => {});
}
const finalCount = await page.evaluate(() => {
const countEl = document.getElementById("lecture-total-count");
const cards = document.querySelectorAll("#lecture-grid .lecture-card, #lecture-grid a.lecture-card");
return { total: countEl ? countEl.textContent.trim() : "?", loaded: cards.length };
});
console.log(" learning loaded:", finalCount.loaded, "/ total", finalCount.total);
}
/** 목록 섹션(element) 캡처 — 카드 전체가 포함되도록 */
async function captureListPanel(page, baseUrl, item, outPath) {
const url = `${baseUrl}${item.path}`;
await page.goto(url, { waitUntil: "networkidle", timeout: 90000 });
if (item.preload === "learning") {
await loadAllLearningPages(page);
}
await page.waitForSelector(item.selector, { timeout: 60000 });
await page.waitForTimeout(600);
const panel = page.locator(item.selector).first();
await panel.scrollIntoViewIfNeeded();
await panel.screenshot({ path: outPath });
const meta = await page.evaluate((sel) => {
const cards =
sel.includes("lecture")
? document.querySelectorAll("#lecture-grid .lecture-card, #lecture-grid a.lecture-card").length
: document.querySelectorAll(".success-story-grid .success-story-card, .success-story-grid a").length;
const chip = document.querySelector(".count-chip");
return { cards, chip: chip ? chip.textContent.trim() : "" };
}, item.selector);
console.log(" saved:", outPath, meta.chip || "", "visible cards:", meta.cards);
}
async function main() {
await fs.mkdir(OUT_DIR, { recursive: true });
const browser = await chromium.launch({ headless: true });
const context = await browser.newContext({
viewport: { width: 1440, height: 900 },
locale: "ko-KR",
});
const page = await context.newPage();
const userEmail = captureUserEmail();
const opsCookie = buildOpsSessionCookie(userEmail);
const cookieJar = [
{
name: "ops_user_session",
value: opsCookie,
url: LOCAL_BASE,
httpOnly: true,
sameSite: "Lax",
},
];
if (CAPTURE_LISTS_FROM_PROD) {
cookieJar.push({
name: "ops_user_session",
value: opsCookie,
domain: new URL(PROD_BASE).hostname,
path: "/",
httpOnly: true,
secure: true,
sameSite: "Lax",
});
}
await context.addCookies(cookieJar);
console.log("Capture persona:", userEmail, "(no admin cookie)", "profile:", CAPTURE_PROFILE);
console.log("Output dir:", OUT_DIR);
console.log("[1/4] Login (production)...");
try {
await capturePage(
page,
PROD_LOGIN,
path.join(OUT_DIR, "login.png"),
1500
);
} catch (err) {
console.warn(" login capture failed:", err.message);
}
console.log("[2/4] Local menus (PROD + OPS cookie, no admin)...");
for (const item of LOCAL_PAGES) {
const outPath = path.join(OUT_DIR, `${item.id}.png`);
try {
await capturePage(page, `${LOCAL_BASE}${item.path}`, outPath, item.waitMs);
} catch (err) {
console.warn(` ${item.id} failed:`, err.message);
}
}
console.log("[2b/4] List panels (full load + element capture)...");
for (const item of LIST_PANEL_PAGES) {
const outPath = path.join(OUT_DIR, `${item.id}.png`);
const base = item.useProd && CAPTURE_LISTS_FROM_PROD ? PROD_BASE : LOCAL_BASE;
try {
await captureListPanel(page, base, item, outPath);
} catch (err) {
console.warn(` ${item.id} list panel failed (${base}):`, err.message);
if (base !== LOCAL_BASE) {
try {
await captureListPanel(page, LOCAL_BASE, item, outPath);
} catch (err2) {
console.warn(` ${item.id} local fallback failed:`, err2.message);
}
}
}
}
console.log("[3/4] Menu overview...");
try {
await capturePage(
page,
`${LOCAL_BASE}${MENU_OVERVIEW.path}`,
path.join(OUT_DIR, `${MENU_OVERVIEW.id}.png`),
MENU_OVERVIEW.waitMs
);
} catch (err) {
console.warn(" menu-overview failed:", err.message);
}
if (CAPTURE_PROFILE !== "general") {
console.log("[4/4] Dashboard (허용 계정:", userEmail, ")...");
for (const item of [
{ id: "dashboard", path: "/dashboard", waitMs: 1000 },
{ id: "dashboard-business-performance", path: "/dashboard/business-performance", waitMs: 2500 },
]) {
const outPath = path.join(OUT_DIR, `${item.id}.png`);
try {
await capturePage(page, `${LOCAL_BASE}${item.path}`, outPath, item.waitMs);
} catch (err) {
console.warn(` ${item.id} failed:`, err.message);
}
}
} else {
console.log("[4/4] Dashboard skip (general profile)");
}
await browser.close();
console.log("Done. Output:", OUT_DIR);
}
main().catch((err) => {
console.error(err);
process.exit(1);
});

24
scripts/lib/load-env.sh Executable file
View File

@@ -0,0 +1,24 @@
#!/usr/bin/env bash
# 프로젝트 루트 .env에서 KEY=VALUE 줄만 export (섹션 헤더 [..]·주석 무시)
load_project_env() {
local env_file="$1"
if [[ ! -f "$env_file" ]]; then
echo "load_project_env: .env not found: $env_file" >&2
return 1
fi
while IFS= read -r line || [[ -n "$line" ]]; do
line="${line#"${line%%[![:space:]]*}"}"
[[ -z "$line" || "$line" == \#* ]] && continue
[[ "$line" == \[* ]] && continue
if [[ "$line" =~ ^([A-Za-z_][A-Za-z0-9_]*)=(.*)$ ]]; then
local key="${BASH_REMATCH[1]}"
local val="${BASH_REMATCH[2]}"
if [[ "$val" =~ ^\"(.*)\"$ ]]; then
val="${BASH_REMATCH[1]}"
elif [[ "$val" =~ ^\'(.*)\'$ ]]; then
val="${BASH_REMATCH[1]}"
fi
export "$key=$val"
fi
done < "$env_file"
}

View File

@@ -0,0 +1,122 @@
#!/usr/bin/env node
/**
* data/ai-success-stories.json 에는 없는데 data/ai-success-stories/*.md 만 남은 경우( Git pull로 json만 예전으로 돌아간 경우 등 )
* 본문 파일을 스캔해 메타 항목을 자동 추가합니다.
*
* 사용: 프로젝트 루트에서
* node scripts/merge-orphan-ai-success-stories.js
*
* 동작: json 백업(data/ai-success-stories.json.bak.<시간>) 후, 등록되지 않은 .md 마다 항목 추가.
* 제목은 md 첫 줄의 # 제목을 쓰고, 없으면 슬러그에서 추정한 임시 제목.
* pdfUrl은 비어 있음 → 관리자 /ai-cases/write 에서 해당 사례 편집 후 PDF 경로를 다시 넣으세요.
*/
const path = require("path");
const fs = require("fs");
const ROOT = path.join(__dirname, "..");
const DATA_DIR = path.join(ROOT, "data");
const META_PATH = path.join(DATA_DIR, "ai-success-stories.json");
const CONTENT_DIR = path.join(DATA_DIR, "ai-success-stories");
function parseSlugFromMdName(basename) {
const noExt = basename.replace(/\.md$/i, "");
const m = noExt.match(/^(.+)-(\d{10,20})$/);
if (m) return m[1].toLowerCase();
return noExt.toLowerCase().replace(/[^a-z0-9\-]/g, "") || "story";
}
function firstHeadingOrTitle(md, slug) {
const lines = md.split(/\r?\n/);
for (const line of lines) {
const t = line.trim();
if (t.startsWith("# ")) return t.slice(2).trim();
}
return (
slug
.split("-")
.filter(Boolean)
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
.join(" ") || "사례 (제목 확인)"
);
}
function excerptFromMd(md) {
const lines = md.split(/\r?\n/).map((l) => l.trim());
const paras = [];
for (const line of lines) {
if (!line || line.startsWith("#")) continue;
if (line.startsWith("```")) break;
paras.push(line);
if (paras.join(" ").length > 200) break;
}
const e = paras.join(" ").slice(0, 280);
return e || "";
}
function main() {
if (!fs.existsSync(META_PATH)) {
console.error("없음:", META_PATH);
process.exit(1);
}
if (!fs.existsSync(CONTENT_DIR)) {
console.error("없음:", CONTENT_DIR);
process.exit(1);
}
const raw = fs.readFileSync(META_PATH, "utf8");
const meta = JSON.parse(raw);
if (!Array.isArray(meta)) {
console.error("ai-success-stories.json 형식이 배열이 아닙니다.");
process.exit(1);
}
const used = new Set(meta.map((m) => (m.contentFile || "").split("/").pop()).filter(Boolean));
const files = fs.readdirSync(CONTENT_DIR).filter((f) => /\.md$/i.test(f));
const orphans = files.filter((f) => !used.has(f));
if (orphans.length === 0) {
console.log("추가할 고아 .md 없음. 종료.");
return;
}
const bak = `${META_PATH}.bak.${Date.now()}`;
fs.copyFileSync(META_PATH, bak);
console.log("백업:", bak);
for (const file of orphans) {
const full = path.join(CONTENT_DIR, file);
const md = fs.readFileSync(full, "utf8");
const slug = parseSlugFromMdName(file);
if (meta.some((m) => m.slug === slug)) {
console.warn("슬러그 충돌 건너뜀 (이미 다른 항목에 동일 slug):", slug, file);
continue;
}
const title = firstHeadingOrTitle(md, slug);
const excerpt = excerptFromMd(md) || title.slice(0, 140);
const tsMatch = file.match(/-(\d{10,20})\.md$/i);
const id = tsMatch ? `story-${tsMatch[1]}` : `story-${Date.now()}`;
const row = {
id,
slug,
title,
excerpt,
author: "",
department: "",
publishedAt: new Date().toISOString().slice(0, 10),
tags: [],
contentFile: file,
pdfUrl: "",
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
meta.push(row);
console.log("추가:", file, "→ slug:", slug);
}
fs.writeFileSync(META_PATH, JSON.stringify(meta, null, 2), "utf8");
console.log("저장 완료:", META_PATH, "총", meta.length, "건");
console.log("pdfUrl이 비어 있으면 관리자 화면에서 PDF 경로를 지정하세요.");
}
main();

181
scripts/pg-backup.sh Executable file
View File

@@ -0,0 +1,181 @@
#!/usr/bin/env bash
# PostgreSQL 논리 백업 (pg_dump -Fc). .env의 DB_* 및 PG_BACKUP_* 사용.
# PG_BACKUP_SCOPE=all 이면 서버의 연결 가능·비템플릿 DB 전체를 각각 .dump 로 저장.
# 운영 서버 cron 예: 0 2 * * * cd /home/xavis/workspace/ai_platform && bash scripts/pg-backup.sh >> /var/log/pg-backup.log 2>&1
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
# shellcheck source=scripts/lib/load-env.sh
source "$REPO_ROOT/scripts/lib/load-env.sh"
load_project_env "$REPO_ROOT/.env"
: "${DB_HOST:?DB_HOST is required in .env}"
: "${DB_DATABASE:?DB_DATABASE is required in .env}"
: "${DB_USERNAME:?DB_USERNAME is required in .env}"
: "${DB_PASSWORD:?DB_PASSWORD is required in .env}"
DB_PORT="${DB_PORT:-5432}"
PG_BACKUP_DIR="${PG_BACKUP_DIR:-/home/xavis/workspace/backup/ai_platform}"
PG_BACKUP_RETENTION_DAYS="${PG_BACKUP_RETENTION_DAYS:-30}"
PG_BACKUP_SCOPE="${PG_BACKUP_SCOPE:-all}"
PG_BACKUP_GLOBALS="${PG_BACKUP_GLOBALS:-1}"
export PGHOST="$DB_HOST"
export PGPORT="$DB_PORT"
if ! command -v pg_dump >/dev/null 2>&1; then
echo "pg-backup: pg_dump not found. Install postgresql-client (apt) or postgresql (brew)." >&2
exit 1
fi
if ! command -v psql >/dev/null 2>&1; then
echo "pg-backup: psql not found. Install postgresql-client." >&2
exit 1
fi
log_ts() { date '+%Y-%m-%dT%H:%M:%S%z'; }
set_backup_credentials() {
if [[ -n "${PG_BACKUP_SUPERUSER_PASSWORD:-}" ]]; then
export PGUSER="${PG_BACKUP_SUPERUSER:-postgres}"
export PGPASSWORD="$PG_BACKUP_SUPERUSER_PASSWORD"
else
export PGUSER="$DB_USERNAME"
export PGPASSWORD="$DB_PASSWORD"
fi
}
list_all_databases() {
psql -h "$PGHOST" -p "$PGPORT" -U "$PGUSER" -d postgres -v ON_ERROR_STOP=1 -tAc \
"SELECT datname FROM pg_database WHERE datallowconn AND NOT datistemplate ORDER BY datname;"
}
dump_database() {
local db_name="$1"
local dump_file="$2"
pg_dump \
-h "$PGHOST" \
-p "$PGPORT" \
-U "$PGUSER" \
-d "$db_name" \
-Fc \
--no-password \
-f "$dump_file"
}
backup_globals() {
if [[ "$PG_BACKUP_GLOBALS" != "1" ]]; then
return 0
fi
if [[ -z "${PG_BACKUP_SUPERUSER_PASSWORD:-}" ]]; then
echo "[$(log_ts)] globals skipped: set PG_BACKUP_SUPERUSER_PASSWORD for role/global backup." >&2
return 0
fi
local prev_user="$PGUSER" prev_pass="$PGPASSWORD"
export PGUSER="${PG_BACKUP_SUPERUSER:-postgres}"
export PGPASSWORD="$PG_BACKUP_SUPERUSER_PASSWORD"
pg_dumpall \
-h "$PGHOST" \
-p "$PGPORT" \
-U "$PGUSER" \
--globals-only \
--no-password \
-f "$RUN_DIR/00_globals.sql"
export PGUSER="$prev_user"
export PGPASSWORD="$prev_pass"
echo "[$(log_ts)] globals saved: $RUN_DIR/00_globals.sql"
}
set_backup_credentials
# cron 일 1회 기준: YYYYMMDD 폴더에 당일 백업 저장
STAMP="$(date +%Y%m%d)"
RUN_DIR="$PG_BACKUP_DIR/$STAMP"
mkdir -p "$RUN_DIR"
echo "[$(log_ts)] pg-backup start → $RUN_DIR (scope=${PG_BACKUP_SCOPE}, retention ${PG_BACKUP_RETENTION_DAYS} days)"
if [[ -z "${PG_BACKUP_SUPERUSER_PASSWORD:-}" && "$PG_BACKUP_SCOPE" == "all" ]]; then
echo "[$(log_ts)] note: PG_BACKUP_SUPERUSER_PASSWORD not set; backing up only databases visible to ${DB_USERNAME}." >&2
fi
backup_globals
declare -a TARGET_DBS=()
if [[ "$PG_BACKUP_SCOPE" == "single" ]]; then
TARGET_DBS=("$DB_DATABASE")
else
while IFS= read -r db; do
[[ -n "$db" ]] && TARGET_DBS+=("$db")
done < <(list_all_databases)
if [[ ${#TARGET_DBS[@]} -eq 0 ]]; then
echo "pg-backup: no databases found to backup." >&2
exit 1
fi
fi
MANIFEST="$RUN_DIR/00_manifest.txt"
{
echo "# pg-backup manifest $(log_ts)"
echo "scope=${PG_BACKUP_SCOPE}"
echo "host=${PGHOST}:${PGPORT}"
echo "user=${PGUSER}"
} > "$MANIFEST"
dump_ok=0
dump_fail=0
for db_name in "${TARGET_DBS[@]}"; do
dump_file="$RUN_DIR/${db_name}.dump"
echo "[$(log_ts)] dumping database: $db_name"
if dump_database "$db_name" "$dump_file"; then
bytes="$(wc -c < "$dump_file" | tr -d ' ')"
echo "[$(log_ts)] dump saved: $dump_file (${bytes} bytes)"
echo "${db_name}.dump ${bytes}" >> "$MANIFEST"
dump_ok=$((dump_ok + 1))
else
echo "[$(log_ts)] dump failed: $db_name" >&2
dump_fail=$((dump_fail + 1))
fi
done
if [[ "$dump_ok" -eq 0 ]]; then
echo "pg-backup: all database dumps failed." >&2
exit 1
fi
if [[ "$dump_fail" -gt 0 ]]; then
echo "[$(log_ts)] warning: ${dump_fail} database dump(s) failed, ${dump_ok} succeeded." >&2
fi
ln -sfn "$RUN_DIR" "$PG_BACKUP_DIR/latest"
prune_old_backups() {
local cutoff removed=0 base day entry
if date -d "1 day ago" +%Y%m%d >/dev/null 2>&1; then
cutoff=$(date -d "${PG_BACKUP_RETENTION_DAYS} days ago" +%Y%m%d)
else
cutoff=$(date -v-"${PG_BACKUP_RETENTION_DAYS}"d +%Y%m%d)
fi
shopt -s nullglob
for entry in "$PG_BACKUP_DIR"/*; do
[[ -d "$entry" ]] || continue
base=$(basename "$entry")
[[ "$base" == "latest" ]] && continue
if [[ "$base" =~ ^([0-9]{8}) ]]; then
day="${BASH_REMATCH[1]}"
if [[ "$day" < "$cutoff" ]]; then
rm -rf "$entry"
removed=$((removed + 1))
echo "[$(log_ts)] retention: removed $entry (backup date $day < cutoff $cutoff)"
fi
fi
done
shopt -u nullglob
echo "[$(log_ts)] retention: pruned ${removed} dir(s); keeping backups from ${cutoff} onward (${PG_BACKUP_RETENTION_DAYS} days)"
}
if [[ "$PG_BACKUP_RETENTION_DAYS" =~ ^[0-9]+$ ]] && [[ "$PG_BACKUP_RETENTION_DAYS" -gt 0 ]]; then
prune_old_backups
fi
echo "[$(log_ts)] pg-backup done: ${dump_ok} database(s), latest → $PG_BACKUP_DIR/latest"

225
scripts/pg-restore.sh Executable file
View File

@@ -0,0 +1,225 @@
#!/usr/bin/env bash
# PostgreSQL 논리 복원 (pg_restore). custom format(.dump) 전용.
# 사용 예:
# bash scripts/pg-restore.sh /home/xavis/workspace/backup/ai_platform/latest/ai_web_platform.dump
# bash scripts/pg-restore.sh --all --globals --confirm /home/xavis/workspace/backup/ai_platform/latest/
# bash scripts/pg-restore.sh --test --confirm /home/xavis/workspace/backup/ai_platform/latest/ai_web_platform.dump
set -euo pipefail
usage() {
cat <<'EOF'
Usage: bash scripts/pg-restore.sh [options] <path-to.dump | backup-dir>
Options:
--all 백업 디렉터리 안의 *.dump 전체 복원 (00_globals.sql 있으면 --globals 권장)
--test 복원 대상 DB를 {dbname}_restore_test 로 생성 (단일 dump 또는 --all)
--clean pg_restore --clean --if-exists (기존 객체 삭제 후 복원, --test 미사용 시 위험)
--confirm 확인 없이 실행 (cron·자동화용; --test 없이 --clean 시 필수)
--globals 같은 디렉터리의 00_globals.sql 을 먼저 적용 (슈퍼유저 env 필요)
-h, --help 도움말
환경 변수 (.env):
DB_* 연결 정보
PG_BACKUP_SUPERUSER --globals 시 사용 (기본 postgres)
PG_BACKUP_SUPERUSER_PASSWORD
EOF
}
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
# shellcheck source=scripts/lib/load-env.sh
source "$REPO_ROOT/scripts/lib/load-env.sh"
MODE="prod"
CLEAN=0
CONFIRM=0
GLOBALS=0
RESTORE_ALL=0
TARGET_PATH=""
while [[ $# -gt 0 ]]; do
case "$1" in
--test) MODE="test"; shift ;;
--clean) CLEAN=1; shift ;;
--confirm) CONFIRM=1; shift ;;
--globals) GLOBALS=1; shift ;;
--all) RESTORE_ALL=1; shift ;;
-h|--help) usage; exit 0 ;;
-*) echo "Unknown option: $1" >&2; usage >&2; exit 1 ;;
*)
if [[ -n "$TARGET_PATH" ]]; then
echo "Unexpected argument: $1" >&2
exit 1
fi
TARGET_PATH="$1"
shift
;;
esac
done
if [[ -z "$TARGET_PATH" ]]; then
usage >&2
exit 1
fi
load_project_env "$REPO_ROOT/.env"
: "${DB_HOST:?DB_HOST is required in .env}"
: "${DB_DATABASE:?DB_DATABASE is required in .env}"
: "${DB_USERNAME:?DB_USERNAME is required in .env}"
: "${DB_PASSWORD:?DB_PASSWORD is required in .env}"
DB_PORT="${DB_PORT:-5432}"
export PGHOST="$DB_HOST"
export PGPORT="$DB_PORT"
export PGUSER="$DB_USERNAME"
export PGPASSWORD="$DB_PASSWORD"
if ! command -v pg_restore >/dev/null 2>&1; then
echo "pg-restore: pg_restore not found. Install postgresql-client." >&2
exit 1
fi
log_ts() { date '+%Y-%m-%dT%H:%M:%S%z'; }
resolve_superuser_creds() {
SUPERUSER="${PG_BACKUP_SUPERUSER:-postgres}"
if [[ -n "${PG_BACKUP_SUPERUSER_PASSWORD:-}" ]]; then
SU_USER="$SUPERUSER"
SU_PASS="$PG_BACKUP_SUPERUSER_PASSWORD"
else
SU_USER="$DB_USERNAME"
SU_PASS="$DB_PASSWORD"
fi
}
apply_globals() {
local globals_file="$1"
if [[ ! -f "$globals_file" ]]; then
echo "pg-restore: globals file not found: $globals_file" >&2
exit 1
fi
: "${PG_BACKUP_SUPERUSER_PASSWORD:?PG_BACKUP_SUPERUSER_PASSWORD required for --globals}"
resolve_superuser_creds
export PGUSER="$SU_USER"
export PGPASSWORD="$SU_PASS"
echo "[$(log_ts)] applying globals: $globals_file"
psql -h "$PGHOST" -p "$PGPORT" -U "$PGUSER" -v ON_ERROR_STOP=1 -f "$globals_file"
export PGUSER="$DB_USERNAME"
export PGPASSWORD="$DB_PASSWORD"
}
ensure_database() {
local db_name="$1"
resolve_superuser_creds
export PGUSER="$SU_USER"
export PGPASSWORD="$SU_PASS"
psql -h "$PGHOST" -p "$PGPORT" -U "$PGUSER" -v ON_ERROR_STOP=1 -tc \
"SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = '$db_name' AND pid <> pg_backend_pid();" \
>/dev/null 2>&1 || true
if [[ "$MODE" == "test" ]]; then
psql -h "$PGHOST" -p "$PGPORT" -U "$PGUSER" -v ON_ERROR_STOP=1 -c "DROP DATABASE IF EXISTS \"$db_name\";"
psql -h "$PGHOST" -p "$PGPORT" -U "$PGUSER" -v ON_ERROR_STOP=1 -c \
"CREATE DATABASE \"$db_name\" OWNER \"$DB_USERNAME\";"
fi
export PGUSER="$DB_USERNAME"
export PGPASSWORD="$DB_PASSWORD"
}
restore_one_dump() {
local dump_path="$1"
local source_db
source_db="$(basename "$dump_path" .dump)"
local target_db="$source_db"
if [[ "$MODE" == "test" ]]; then
target_db="${source_db}_restore_test"
ensure_database "$target_db"
fi
local restore_args=(
-h "$PGHOST"
-p "$PGPORT"
-U "$PGUSER"
-d "$target_db"
--no-owner
--role="$DB_USERNAME"
-v
)
if [[ "$CLEAN" -eq 1 ]]; then
restore_args+=(--clean --if-exists)
fi
echo "[$(log_ts)] pg_restore → $target_db ($dump_path)"
pg_restore "${restore_args[@]}" "$dump_path"
echo "[$(log_ts)] pg-restore done: $target_db"
}
confirm_restore() {
local message="$1"
if [[ "$MODE" == "prod" && "$CLEAN" -eq 1 && "$CONFIRM" -ne 1 ]]; then
echo "pg-restore: --clean on production DB requires --confirm" >&2
exit 1
fi
if [[ "$CONFIRM" -eq 1 ]]; then
return 0
fi
echo "$message"
read -r -p "Continue? [y/N] " ans
[[ "$ans" == "y" || "$ans" == "Y" ]] || exit 0
}
if [[ -d "$TARGET_PATH" ]]; then
BACKUP_DIR="${TARGET_PATH%/}"
if [[ "$RESTORE_ALL" -ne 1 ]]; then
echo "pg-restore: path is a directory. Use --all to restore every *.dump inside." >&2
exit 1
fi
confirm_restore "About to restore ALL dumps in: $BACKUP_DIR on $DB_HOST:$PGPORT"
if [[ "$GLOBALS" -eq 1 ]]; then
apply_globals "$BACKUP_DIR/00_globals.sql"
fi
shopt -s nullglob
dumps=( "$BACKUP_DIR"/*.dump )
shopt -u nullglob
if [[ ${#dumps[@]} -eq 0 ]]; then
echo "pg-restore: no .dump files in $BACKUP_DIR" >&2
exit 1
fi
for dump in "${dumps[@]}"; do
restore_one_dump "$dump"
done
exit 0
fi
if [[ ! -f "$TARGET_PATH" ]]; then
echo "pg-restore: path not found: $TARGET_PATH" >&2
exit 1
fi
if [[ "$RESTORE_ALL" -eq 1 ]]; then
echo "pg-restore: --all requires a backup directory path." >&2
exit 1
fi
TARGET_DB="$DB_DATABASE"
if [[ "$MODE" == "test" ]]; then
TARGET_DB="${DB_DATABASE}_restore_test"
fi
confirm_restore "About to restore into database: $TARGET_DB on $DB_HOST:$PGPORT
Dump: $TARGET_PATH"
if [[ "$GLOBALS" -eq 1 ]]; then
apply_globals "$(dirname "$TARGET_PATH")/00_globals.sql"
fi
if [[ "$MODE" == "test" ]]; then
ensure_database "$TARGET_DB"
fi
restore_one_dump "$TARGET_PATH"
if [[ "$MODE" == "test" ]]; then
echo "Verify with: psql -h $DB_HOST -U $DB_USERNAME -d $TARGET_DB -c '\\dt'"
echo "Drop test DB: psql ... -c 'DROP DATABASE \"$TARGET_DB\";'"
fi

49
scripts/safe-git-pull.sh Executable file
View File

@@ -0,0 +1,49 @@
#!/usr/bin/env bash
# 배포 서버에서 런타임 data 파일의 **로컬 수정** 때문에
# "Your local changes to the following files would be overwritten by merge" 가 나와
# git pull 이 막힐 때 사용합니다.
# 동작: 지정 경로를 백업 → 인덱스/작업트리를 리셃해 pull을 허용 → 백업을 다시 복사합니다.
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
cd "$REPO_ROOT"
# pull 전에 Git이 덮어쓰기를 거부하는 런타임 전용 경로(필요 시 추가)
PULL_SAFE_PATHS=(
"data/ai-success-stories.json"
)
STAMP="$(date +%Y%m%d%H%M%S)"
BAK_DIR="${REPO_ROOT}/.tmp-safe-pull-backup-${STAMP}"
mkdir -p "$BAK_DIR"
backed=()
for relpath in "${PULL_SAFE_PATHS[@]}"; do
if [[ -f "$relpath" ]]; then
dest="${BAK_DIR}/$(echo "$relpath" | tr / _)"
cp -a "$relpath" "$dest"
backed+=("$relpath|$dest")
fi
done
# 추적 중이면 머지/체크아웃이 막힘 — 먼저 백업했으므로 작업트리·스테이징만 맞춤
for relpath in "${PULL_SAFE_PATHS[@]}"; do
if git ls-files --error-unmatch -- "$relpath" >/dev/null 2>&1; then
if ! git restore --worktree --staged -- "$relpath" 2>/dev/null; then
git reset -q HEAD -- "$relpath" 2>/dev/null || true
git checkout -- "$relpath" 2>/dev/null || true
fi
fi
done
git pull "$@"
for ent in "${backed[@]}"; do
relpath="${ent%%|*}"
src="${ent#*|}"
if [[ -f "$src" ]]; then
cp -a "$src" "$relpath"
fi
done
rm -rf "$BAK_DIR"
echo "safe-git-pull: 완료. 런타임 파일은 백업에서 복원되었습니다."