# -*- coding: utf-8 -*- """ 킨텍스 자동전시시스템 — 지침서 3종 PPTX 일괄 생성기 · 사용자지침서.pptx · 운영자지침서.pptx · 개발자지침서.pptx - 디자인: docs/deliverables/_gen/gen_dev_plan_pptx.py 스타일 헬퍼 그대로 차용 (KINTEX Blue #0066B3 / AI Purple #6D4AFF / 맑은 고딕 / 16:9, 표지·간지·본문 마스터, 도식은 python-pptx 도형 직접 작도) - 내용 소스(사실 기반): docs/PLANNING.md v3.0, docs/design.md v2.0(§2.8 80화면 인벤토리), docs/OPS_RUNBOOK.md, docs/ENV_SETUP.md, docs/BUILD_DEPLOY.md, docs/DEVELOPMENT_GUIDE.md, docs/SECURITY.md, mobile/README.md - 보안 불변: 비밀번호/API키/서버IP/SSH 계정 미기재(env 키 '이름'만 언급, 값 금지) 재실행: python gen_guides_pptx.py """ import os from pptx import Presentation from pptx.util import Pt from pptx.dml.color import RGBColor from pptx.enum.text import PP_ALIGN, MSO_ANCHOR from pptx.enum.shapes import MSO_SHAPE from pptx.oxml.ns import qn # ---------------- 팔레트 (개발계획서 동일) ---------------- BLUE = RGBColor(0x00, 0x66, 0xB3) BLUE_DK = RGBColor(0x00, 0x44, 0x7A) BLUE_LT = RGBColor(0xE1, 0xEF, 0xF9) PURPLE = RGBColor(0x6D, 0x4A, 0xFF) PURPLE_LT = RGBColor(0xEC, 0xE8, 0xFF) INK = RGBColor(0x10, 0x18, 0x28) MUTED = RGBColor(0x66, 0x70, 0x85) LINE = RGBColor(0xE4, 0xE7, 0xEC) BG = RGBColor(0xF9, 0xFA, 0xFB) WHITE = RGBColor(0xFF, 0xFF, 0xFF) GREEN = RGBColor(0x12, 0x9E, 0x63) AMBER = RGBColor(0xE0, 0x8A, 0x00) RED = RGBColor(0xD0, 0x3A, 0x3A) SLATE = RGBColor(0x98, 0xA2, 0xB3) CARD = RGBColor(0xFF, 0xFF, 0xFF) HALL_BG = RGBColor(0xF2, 0xF4, 0xF7) FONT = "맑은 고딕" EMU_W, EMU_H = 12192000, 6858000 IN = 914400 def inch(v): return int(v * IN) # 문서 컨텍스트 (build 시 세팅) PRS = None BLANK = None DOC_FOOTER = "" DOC_DATE = "2026-07-11" # ---------------- 헬퍼 ---------------- def slide(): return PRS.slides.add_slide(BLANK) def _set_fill(shape, color): if color is None: shape.fill.background() else: shape.fill.solid() shape.fill.fore_color.rgb = color def _no_line(shape): shape.line.fill.background() def rect(s, x, y, w, h, fill=None, line=None, line_w=1.0, shape=MSO_SHAPE.RECTANGLE, shadow=False): sp = s.shapes.add_shape(shape, inch(x), inch(y), inch(w), inch(h)) _set_fill(sp, fill) if line is None: _no_line(sp) else: sp.line.color.rgb = line sp.line.width = Pt(line_w) sp.shadow.inherit = False if shadow: el = sp._element.spPr ef = el.find(qn('a:effectLst')) if ef is None: ef = el.makeelement(qn('a:effectLst'), {}) el.append(ef) sh = ef.makeelement(qn('a:outerShdw'), {'blurRad':'90000','dist':'38100','dir':'5400000','rotWithShape':'0'}) clr = sh.makeelement(qn('a:srgbClr'), {'val':'101828'}) alp = clr.makeelement(qn('a:alpha'), {'val':'18000'}) clr.append(alp); sh.append(clr); ef.append(sh) return sp def text(s, x, y, w, h, content, size=14, color=INK, bold=False, align=PP_ALIGN.LEFT, anchor=MSO_ANCHOR.TOP, line_spacing=1.0, space_after=2, wrap=True): tb = s.shapes.add_textbox(inch(x), inch(y), inch(w), inch(h)) tf = tb.text_frame tf.word_wrap = wrap tf.vertical_anchor = anchor tf.margin_left = 0; tf.margin_right = 0; tf.margin_top = 0; tf.margin_bottom = 0 if isinstance(content, str): content = [content] for i, para in enumerate(content): p = tf.paragraphs[0] if i == 0 else tf.add_paragraph() p.alignment = align p.line_spacing = line_spacing p.space_after = Pt(space_after) p.space_before = Pt(0) if isinstance(para, str): segs = [{"t": para}] elif isinstance(para, dict): segs = [para] else: segs = para for seg in segs: r = p.add_run() r.text = seg.get("t","") r.font.name = FONT r.font.size = Pt(seg.get("size", size)) r.font.bold = seg.get("bold", bold) r.font.color.rgb = seg.get("color", color) rPr = r._r.get_or_add_rPr() ea = rPr.makeelement(qn('a:ea'), {'typeface':FONT}) rPr.append(ea) return tb def page_bg(s, color=BG): rect(s, -0.1, -0.1, 13.53, 7.7, fill=color) def chip(s, x, y, w, h, label, fill, txtcolor=WHITE, size=9.5, bold=True): rect(s, x, y, w, h, fill=fill, shape=MSO_SHAPE.ROUNDED_RECTANGLE) text(s, x, y, w, h, label, size=size, color=txtcolor, bold=bold, align=PP_ALIGN.CENTER, anchor=MSO_ANCHOR.MIDDLE) def card(s, x, y, w, h, fill=CARD, line=LINE): return rect(s, x, y, w, h, fill=fill, line=line, line_w=1.0, shape=MSO_SHAPE.ROUNDED_RECTANGLE, shadow=True) def content_header(s, kicker, title, idx): page_bg(s, BG) rect(s, 0, 0, 13.333, 0.12, fill=BLUE) rect(s, 0.55, 0.5, 0.62, 0.62, fill=BLUE, shape=MSO_SHAPE.ROUNDED_RECTANGLE) text(s, 0.55, 0.5, 0.62, 0.62, f"{idx:02d}", size=20, color=WHITE, bold=True, align=PP_ALIGN.CENTER, anchor=MSO_ANCHOR.MIDDLE) text(s, 1.32, 0.5, 10.5, 0.28, kicker, size=11, color=PURPLE, bold=True) text(s, 1.32, 0.74, 11.4, 0.44, title, size=22, color=INK, bold=True) rect(s, 0.55, 1.28, 12.23, 0.02, fill=LINE) text(s, 0.55, 7.06, 9, 0.3, DOC_FOOTER, size=8.5, color=MUTED) text(s, 11.3, 7.06, 1.48, 0.3, DOC_DATE, size=8.5, color=MUTED, align=PP_ALIGN.RIGHT) def bullets(s, x, y, w, items, dot=BLUE, size=10.5, gap=0.46, color=INK, ls=1.0, dotsz=0.1): """items: str 또는 [head, desc] 튜플""" for i, it in enumerate(items): yy = y + i*gap rect(s, x, yy+0.07, dotsz, dotsz, fill=dot, shape=MSO_SHAPE.OVAL) if isinstance(it, (tuple, list)): text(s, x+0.24, yy, w-0.24, gap, [[{"t":it[0]+" — ","size":size,"color":INK,"bold":True}, {"t":it[1],"size":size-0.6,"color":MUTED}]], line_spacing=ls) else: text(s, x+0.24, yy, w-0.24, gap, it, size=size, color=color, line_spacing=ls) def titled_card(s, x, y, w, h, title, tcolor, items, item_size=9.6, gap=0.4): card(s, x, y, w, h) rect(s, x, y, w, 0.5, fill=tcolor, shape=MSO_SHAPE.ROUNDED_RECTANGLE) text(s, x, y, w, 0.5, title, size=12.5, color=WHITE, bold=True, align=PP_ALIGN.CENTER, anchor=MSO_ANCHOR.MIDDLE) for i, it in enumerate(items): yy = y+0.66+i*gap rect(s, x+0.2, yy+0.07, 0.09, 0.09, fill=tcolor, shape=MSO_SHAPE.OVAL) if isinstance(it, (tuple, list)): text(s, x+0.4, yy, w-0.55, gap, [[{"t":it[0]+" ","size":item_size,"color":INK,"bold":True}, {"t":it[1],"size":item_size-0.8,"color":MUTED}]], line_spacing=0.95) else: text(s, x+0.4, yy, w-0.55, gap, it, size=item_size, color=INK, line_spacing=0.95) def steps(s, x, y, w, items, band=BLUE, sep="›"): """가로 단계 파이프라인. items: [(title, sub, color)]""" n = len(items); g = 0.14 bw = (w - (n-1)*g) / n for i, (t, sub, cl) in enumerate(items): bx = x + i*(bw+g) rect(s, bx, y, bw, 0.78, fill=WHITE, line=cl, line_w=1.3, shape=MSO_SHAPE.ROUNDED_RECTANGLE) text(s, bx, y+0.1, bw, 0.3, t, size=11, color=cl, bold=True, align=PP_ALIGN.CENTER) if sub: text(s, bx, y+0.42, bw, 0.3, sub, size=7.6, color=MUTED, align=PP_ALIGN.CENTER) if i < n-1: text(s, bx+bw-0.03, y, 0.22, 0.78, sep, size=16, color=SLATE, bold=True, align=PP_ALIGN.CENTER, anchor=MSO_ANCHOR.MIDDLE) # ---------------- 공통 슬라이드: 표지 ---------------- def cover(badge_kr, badge_en, title_lines, subtitle, closing, meta_pairs): s = slide() rect(s, -0.1, -0.1, 13.53, 7.7, fill=BLUE_DK) rect(s, 6.4, -1.5, 9, 11, fill=BLUE, shape=MSO_SHAPE.PARALLELOGRAM) rect(s, 9.2, -2.0, 7, 12, fill=PURPLE, shape=MSO_SHAPE.PARALLELOGRAM) rect(s, 0.85, 0.8, 0.42, 0.42, fill=WHITE, shape=MSO_SHAPE.OVAL) rect(s, 0.97, 0.92, 0.18, 0.18, fill=PURPLE, shape=MSO_SHAPE.OVAL) text(s, 1.42, 0.78, 6, 0.46, "KINTEX · WISE AI", size=15, color=WHITE, bold=True, anchor=MSO_ANCHOR.MIDDLE) chip(s, 0.85, 2.35, 1.95, 0.42, badge_kr, PURPLE, WHITE, size=12) chip(s, 2.92, 2.35, 2.35, 0.42, badge_en, RGBColor(0x2B,0x4C,0x7E), WHITE, size=10.5) text(s, 0.85, 2.95, 11, 1.9, [[{"t":ln, "size":46, "color":WHITE, "bold":True}] for ln in title_lines], line_spacing=1.02) text(s, 0.88, 2.95 + 0.92*len(title_lines) + 0.08, 11, 0.5, subtitle, size=16, color=RGBColor(0xC7,0xDD,0xF2)) text(s, 0.88, 5.35, 11.4, 0.4, closing, size=12.5, color=RGBColor(0xB9,0xC7,0xEA)) rect(s, 0.85, 6.15, 11.63, 0.02, fill=RGBColor(0x3A,0x5A,0x8C)) ly = 6.35 for label, val in meta_pairs[:2]: text(s, 0.85, ly, 9, 0.4, [[{"t":label+" ","size":11,"color":RGBColor(0x90,0xA6,0xC9)}, {"t":val,"size":11,"color":WHITE,"bold":True}]]) ly += 0.37 text(s, 9.7, 6.35, 2.78, 0.4, "작성일 "+DOC_DATE, size=11, color=WHITE, bold=True, align=PP_ALIGN.RIGHT) text(s, 9.7, 6.72, 2.78, 0.4, "KINTEX 자동전시시스템", size=9.5, color=RGBColor(0x90,0xA6,0xC9), align=PP_ALIGN.RIGHT) # ---------------- 공통 슬라이드: 목차 ---------------- def toc(items): s = slide() content_header(s, "CONTENTS", "목차", 0) rect(s, 0.55, 0.5, 0.62, 0.62, fill=PURPLE, shape=MSO_SHAPE.ROUNDED_RECTANGLE) text(s, 0.55, 0.5, 0.62, 0.62, "C", size=22, color=WHITE, bold=True, align=PP_ALIGN.CENTER, anchor=MSO_ANCHOR.MIDDLE) n = len(items) per_col = (n + 1) // 2 if n > 5 else n col_w = 5.85 for i, (no, t, d) in enumerate(items): col = i // per_col row = i % per_col x = 0.7 + col * (col_w + 0.35) y = 1.62 + row * 1.02 rect(s, x, y, col_w, 0.88, fill=CARD, line=LINE, line_w=1.0, shape=MSO_SHAPE.ROUNDED_RECTANGLE) rect(s, x, y, 0.09, 0.88, fill=BLUE if col==0 else PURPLE, shape=MSO_SHAPE.ROUNDED_RECTANGLE) text(s, x+0.28, y, 0.9, 0.88, no, size=24, color=(BLUE if col==0 else PURPLE), bold=True, anchor=MSO_ANCHOR.MIDDLE) text(s, x+1.15, y+0.15, col_w-1.3, 0.35, t, size=14.5, color=INK, bold=True) text(s, x+1.15, y+0.5, col_w-1.3, 0.32, d, size=9.5, color=MUTED) # ---------------- 공통 슬라이드: 간지 ---------------- def divider(num, kr, en, points): s = slide() rect(s, -0.1, -0.1, 13.53, 7.7, fill=BLUE_DK) rect(s, 8.6, -1.5, 8, 11, fill=BLUE, shape=MSO_SHAPE.PARALLELOGRAM) rect(s, 10.9, -2, 6, 12, fill=PURPLE, shape=MSO_SHAPE.PARALLELOGRAM) text(s, 1.0, 2.15, 5, 2.2, num, size=150, color=RGBColor(0x2C,0x4E,0x82), bold=True) rect(s, 1.15, 3.9, 0.9, 0.09, fill=PURPLE) text(s, 1.15, 4.05, 8, 0.8, kr, size=34, color=WHITE, bold=True) text(s, 1.18, 4.85, 8, 0.4, en, size=13, color=RGBColor(0x9C,0xB4,0xD8), bold=True) for i, p in enumerate(points): yy = 2.35 + i*0.62 rect(s, 8.05, yy+0.06, 0.16, 0.16, fill=PURPLE, shape=MSO_SHAPE.OVAL) text(s, 8.35, yy, 4.4, 0.5, p, size=12.5, color=WHITE, anchor=MSO_ANCHOR.MIDDLE) return s # 안내(고지) 배너 def banner(s, y, msg, fill=BLUE, h=0.62, size=13, x=0.55, w=12.23): rect(s, x, y, w, h, fill=fill, shape=MSO_SHAPE.ROUNDED_RECTANGLE) text(s, x+0.3, y, w-0.6, h, msg, size=size, color=WHITE, bold=True, anchor=MSO_ANCHOR.MIDDLE) # ===================================================================== # 1) 사용자지침서 # ===================================================================== def build_user(): global PRS, BLANK, DOC_FOOTER PRS = Presentation(); PRS.slide_width = EMU_W; PRS.slide_height = EMU_H BLANK = PRS.slide_layouts[6] DOC_FOOTER = "킨텍스 자동전시시스템 · 사용자지침서" cover("사용자지침서", "User Guide", ["킨텍스 자동전시시스템", "사용자 지침서"], "역할별 사용자를 위한 화면·업무 흐름 안내서", "로그인 → 부스 설계·시각화 → 옥션 → 관람객 등록 → 승인까지 한 흐름", [("대상", "주최자 · 참가업체 · 장치/공사업체 · 홀매니저 · 관람객"), ("구성", "접속 · 대시보드 · 핵심업무 · 모바일 · FAQ")]) toc([ ("01", "시스템 소개 · 접속", "개요 / 로그인·2FA / 아이디기억·비번찾기"), ("02", "역할별 홈 · 대시보드", "6역할 포털 / MDI 탭 / 마일스톤 대시보드"), ("03", "핵심 업무 흐름", "부스 설계·병합 / 배선·신청 / 시각화 / 옥션 / 등록·승인"), ("04", "모바일 앱 사용법", "현장 체크리스트 / 검수 / 시각화 갤러리"), ("05", "FAQ · 문의", "자주 묻는 질문 / 지원 채널"), ]) # ---- 01 소개·접속 ---- divider("01", "시스템 소개 · 접속", "INTRODUCTION & SIGN-IN", ["전시 생애주기 폐루프", "역할별 포털", "로그인 · 2차 인증"]) s = slide(); content_header(s, "01 · 시스템 소개", "무엇을 할 수 있나요", 1) text(s, 0.55, 1.45, 12.2, 0.5, "킨텍스 자동전시시스템은 홀 배정·부스 설계·유틸리티 배선·규정 검증·시공 발주(옥션)·관람객 운영·경영분석을 하나의 계정으로 잇는 전시 운영 플랫폼입니다.", size=11.5, color=MUTED) banner(s, 2.05, "핵심 “신청서를 내는 순간, 시공 후 사진을 먼저 봅니다.” — 나노바나나(AI) 시공 예상 이미지", fill=BLUE, size=15) loop = [("판매·기획", BLUE), ("AI 설계·시각화", PURPLE), ("공사 옥션", BLUE), ("시공", BLUE), ("운영·관람", BLUE), ("경영분석", PURPLE)] bw = 1.83; gap = 0.19; x0 = 0.55; yb = 3.05 for i, (lb, cl) in enumerate(loop): x = x0 + i*(bw+gap) rect(s, x, yb, bw, 0.7, fill=(PURPLE_LT if cl==PURPLE else BLUE_LT), line=cl, line_w=1.2, shape=MSO_SHAPE.ROUNDED_RECTANGLE) text(s, x, yb, bw, 0.7, lb, size=11.5, color=(PURPLE if cl==PURPLE else BLUE_DK), bold=True, align=PP_ALIGN.CENTER, anchor=MSO_ANCHOR.MIDDLE) if i < len(loop)-1: text(s, x+bw-0.03, yb, 0.28, 0.7, "→", size=15, color=SLATE, bold=True, align=PP_ALIGN.CENTER, anchor=MSO_ANCHOR.MIDDLE) titled_card(s, 0.55, 4.1, 3.95, 2.55, "내가 얻는 것", BLUE, [ ("주최자", "자동 견적·배치·마일스톤 트래킹"), ("참가업체", "클릭 신청 + 시공 전 부스 사진"), ("장치·공사업체", "제출 전 규정검증·옥션 응찰"), ("홀매니저", "위반 자동 플래깅·부하 집계"), ("관람객", "사전등록·모바일 배지·길찾기"), ], item_size=9.8, gap=0.36) titled_card(s, 4.65, 4.1, 3.95, 2.55, "AI가 돕는 일", PURPLE, [ ("배치 3안", "조건 입력 → 3안 자동생성"), ("규정 검증", "높이·통로·바닥하중 자동 점검"), ("배선 설계", "트렌치 최단 경로·자동 견적"), ("시각화", "시공 후 예상 사진(S1~S7)"), ("검수 보조", "AI 사전심사·위반 플래그"), ], item_size=9.8, gap=0.36) titled_card(s, 8.75, 4.1, 4.03, 2.55, "꼭 알아둘 점", BLUE_DK, [ ("AI 이미지", "‘예상’ 이미지 — 실물과 다를 수 있음"), ("워터마크", "계약·인허가 서류로 사용 불가"), ("등록업체", "미등록 업체는 초대·응찰 불가"), ("최종 판정", "소방·구조는 관할기관·기술사 몫"), ("2차 인증", "로그인 시 OTP/이메일 코드 필요"), ], item_size=9.8, gap=0.36) s = slide(); content_header(s, "01 · 접속 · 로그인", "로그인 · 2차 인증 · 계정 관리", 1) steps(s, 0.55, 1.5, 12.23, [ ("① 이메일·비밀번호", "또는 킨텍스 SSO", BLUE), ("② 2차 인증", "OTP 또는 이메일 코드", PURPLE), ("③ 행사 선택", "워크스페이스 카드", BLUE), ("④ 역할별 홈", "대시보드 진입", GREEN), ]) titled_card(s, 0.55, 2.55, 6.05, 2.15, "로그인 화면(SCR-01)", BLUE, [ "이메일/비밀번호 또는 ‘킨텍스 내부 계정(SSO)’ 로그인", "‘아이디 기억’ 체크 — 이메일만 저장(비밀번호는 저장하지 않음)", "1차 로그인 후 2차 인증코드 입력(OTP/이메일)", "로그인 후 참여 행사 카드에서 워크스페이스 선택", "초대 코드로 참여 — 하단 ‘초대 코드로 참여하기’", ], item_size=9.8, gap=0.36) titled_card(s, 6.75, 2.55, 6.03, 2.15, "2차 인증·계정(SCR-49~51)", PURPLE, [ ("회원가입", "SCR-49 웹 회원가입 폼"), ("비밀번호 찾기", "SCR-50 이메일 인증 후 재설정"), ("OTP 설정", "SCR-51 최초 QR 등록·재설정"), ("잠금 해제", "실패 반복 잠금 시 관리자 초기화"), ("마이페이지", "SCR-48 OTP 재설정·개인화"), ], item_size=9.8, gap=0.36) banner(s, 4.95, "안내 미등록 장치업체가 초대를 수락하면 ‘킨텍스 등록업체만 참여할 수 있습니다’ 안내와 함께 차단됩니다.", fill=BLUE_DK, size=11.5, h=0.5) text(s, 0.55, 5.6, 12.2, 0.9, [ [{"t":"비밀번호를 잊었을 때 ","size":10.5,"color":INK,"bold":True}, {"t":"로그인 화면 → ‘비밀번호 찾기’ → 이메일 인증코드 → 새 비밀번호 설정. ","size":10,"color":MUTED}], [{"t":"OTP 기기를 바꿨을 때 ","size":10.5,"color":INK,"bold":True}, {"t":"마이페이지에서 재설정하거나, 접근 불가 시 관리자에게 OTP 초기화를 요청하세요.","size":10,"color":MUTED}], ], line_spacing=1.15, space_after=6) # ---- 02 역할별 홈·대시보드 ---- divider("02", "역할별 홈 · 대시보드", "ROLE HOMES & DASHBOARD", ["6역할 포털 분리", "MDI 탭 작업환경", "마일스톤 대시보드"]) s = slide(); content_header(s, "02 · 역할별 포털", "내 역할에 맞는 포털로 들어갑니다", 2) text(s, 0.55, 1.4, 12.2, 0.35, "역할마다 목적이 다르므로 포털이 분리되어 있습니다. 데스크톱은 설계·대시보드, 모바일은 현장·조회·승인에 최적화됩니다.", size=10.5, color=MUTED) rect(s, 0.55, 1.9, 12.23, 0.44, fill=BLUE, shape=MSO_SHAPE.ROUNDED_RECTANGLE) for x,w,t in [(0.55,2.0,"역할"),(2.55,4.6,"웹 포털(데스크톱)"),(7.15,3.1,"모바일"),(10.25,2.53,"주 사용")]: text(s, x+0.15, 1.9, w-0.2, 0.44, t, size=10.5, color=WHITE, bold=True, anchor=MSO_ANCHOR.MIDDLE) portals = [ ("주최자", "주최자 콘솔 — 홀 배정·배치·옥션 발주·BI", "조회·승인·상황판", "M1·M2·M15·M16"), ("참가업체", "참가업체 포털 — 부스 신청·설계·유틸리티", "체크인·리드캡처", "M3·M4·M5·M10"), ("장치·공사업체", "업체 포털 — 도면 제출·설계 열람·옥션 응찰", "시공·반입·안전", "M3·M15·M8"), ("홀매니저·운영", "운영 대시보드 — 검수·규정 플래그·부하", "현장 검수·혼잡", "M2·M6·M14"), ("관리자", "백오피스 — 사용자·RBAC·마스터데이터·룰셋", "(웹 전용)", "M18"), ("관람객·대중", "공개 홍보 사이트 — 일정·사전등록", "배지·길찾기·매칭", "M12·M10·M13"), ] ry = 2.42; rh = 0.62 for i,(r,web,mob,mod) in enumerate(portals): y = ry + i*rh if i%2==0: rect(s, 0.55, y, 12.23, rh, fill=RGBColor(0xFC,0xFC,0xFD)) rect(s, 0.55, y, 12.23, 0.01, fill=LINE) text(s, 0.67, y, 1.9, rh, r, size=9.8, color=BLUE_DK, bold=True, anchor=MSO_ANCHOR.MIDDLE) text(s, 2.7, y, 4.45, rh, web, size=9.2, color=INK, anchor=MSO_ANCHOR.MIDDLE) text(s, 7.3, y, 2.95, rh, mob, size=9.2, color=MUTED, anchor=MSO_ANCHOR.MIDDLE) text(s, 10.4, y, 2.4, rh, mod, size=8.8, color=PURPLE, bold=True, anchor=MSO_ANCHOR.MIDDLE) text(s, 0.55, 6.35, 12.2, 0.4, "접속 서브도메인으로 전시관(테넌트)이 고정됩니다 — 자신이 소속되지 않은 전시관의 데이터는 조회·검색되지 않습니다.", size=9.5, color=MUTED) s = slide(); content_header(s, "02 · 대시보드 · 작업환경", "마일스톤 대시보드와 MDI 탭", 2) titled_card(s, 0.55, 1.5, 6.05, 3.3, "주최자 대시보드(SCR-02)", BLUE, [ ("마일스톤 타임라인", "D-150 배정 → D-30 협의 → D-25 유틸리티 → D-7 서류 → D-0 개장"), ("완료·현재·임박", "녹색 체크 / 파란 진행 / 주황 펄스로 표시"), ("KPI 카드", "참가업체 신청·배치 확정률·유틸리티 완료·위반 미해결"), ("참가업체 현황표", "부스번호·설계 상태·유틸리티·예상 사진 썸네일"), ("알림 피드", "승인·반려·마감 이벤트 실시간"), ("홀 전경 조감", "S7 미니 이미지 카드(AI 생성)"), ], item_size=9.6, gap=0.44) titled_card(s, 6.75, 1.5, 6.03, 3.3, "MDI 탭 작업환경(§2.7)", PURPLE, [ ("여러 문서 동시", "부스 에디터·승인 큐·설계 스튜디오를 탭으로 병행"), ("상태 보존", "탭 전환해도 미저장 편집·스크롤·줌 유지"), ("미저장 표시", "탭에 ● 표시, 닫을 때 저장 확인"), ("탭 열기", "메뉴·링크 클릭 → 새 탭(같은 문서는 1개)"), ("단축키", "Ctrl+Tab 전환 · Ctrl+W 닫기 · Ctrl+1~9 이동"), ("모바일", "MDI 미적용 — 한 화면 집중(오조작 방지)"), ], item_size=9.6, gap=0.44) banner(s, 5.0, "팁 좌측 사이드바에서 행사(이벤트)를 바꾸면 그 행사의 탭 세트로 전환됩니다. 미저장 탭이 있으면 먼저 확인합니다.", fill=BLUE_DK, size=11, h=0.5) text(s, 0.55, 5.7, 12.2, 0.9, [ [{"t":"화면 딥링크 ","size":10.5,"color":INK,"bold":True}, {"t":"알림·메일 링크를 클릭하면 해당 문서 탭이 바로 열립니다(예: 검수 상세·배치 에디터). ","size":10,"color":MUTED}], [{"t":"세션 복원 ","size":10.5,"color":INK,"bold":True}, {"t":"새로고침·재접속 시 열려 있던 탭 목록이 복원됩니다(서버 미저장 편집 내용 자체는 복원되지 않음).","size":10,"color":MUTED}], ], line_spacing=1.15, space_after=6) # ---- 03 핵심 업무 흐름 ---- divider("03", "핵심 업무 흐름", "CORE WORKFLOWS", ["부스 설계 3안·병합", "배선·신청·규정검증", "시각화 · 옥션 · 등록·승인"]) s = slide(); content_header(s, "03 · 부스 설계 스튜디오", "3안 생성 · 선택 · 병합 (M2·M3)", 3) steps(s, 0.55, 1.5, 12.23, [ ("① 조건 입력", "홀·목표부스·비율", BLUE), ("② AI 3안 생성", "1·2·3안 자동", PURPLE), ("③ 선택 / 병합", "구역·블록 조합", BLUE), ("④ 규정 재검증", "병합 후 필수", AMBER), ("⑤ 확정·버전", "부스번호 발급", GREEN), ]) titled_card(s, 0.55, 2.6, 6.05, 2.7, "배치·설계(SCR-03·04·06)", BLUE, [ ("AI 자동배치", "보라 버튼 → 제약조건 풀이로 3안 생성"), ("배치안 비교", "S7 조감 이미지·지표(부스수·통로폭·위반) 비교"), ("선택 또는 병합", "1안 통로 + 2안 프리미엄존 + 3안 무대 조합"), ("위반 오버레이", "통로 미달 빨강 밴드·바닥하중 초과 주황 빗금"), ("부스 설계", "조립부스 옵션 선택 / 독립부스 3안 레이아웃"), ], item_size=9.6, gap=0.44) titled_card(s, 6.75, 2.6, 6.03, 2.7, "규정 검증·검수(SCR-09~11)", PURPLE, [ ("제출 전 자동검증", "높이 5m·리깅 6.5~8.5m·복층·방염 체크리스트"), ("규정 리포트", "차단/경고 요약 + 규정 근거·위치 보기"), ("승인 큐", "홀매니저 AI 사전심사 플래그로 검토"), ("검수 상세", "도면 위 위반 오버레이 확인 후 승인/반려"), ("최종 판정", "소방·구조 유권해석은 관할기관·구조기술사"), ], item_size=9.6, gap=0.44) banner(s, 5.5, "중요 ‘병합’으로 제약이 깨질 수 있으므로, 병합 결과는 반드시 규정 검증을 다시 실행한 뒤 확정하세요.", fill=AMBER, size=11.5, h=0.5) s = slide(); content_header(s, "03 · 유틸리티 · 시각화", "배선 신청과 시각화 갤러리 (M4·M5)", 3) titled_card(s, 0.55, 1.5, 6.05, 3.15, "유틸리티 배선·신청(SCR-07·08)", BLUE, [ ("기기 목록 입력", "전시장비·조명·PC → kW 합산·분전반 자동 산출"), ("단말 위치 클릭", "부스 도면 위 클릭 → 트렌치 최단 배선 자동"), ("위치표시도 자동", "수기 작도 폐지 — 자동 생성"), ("자동 견적", "전기·급배수·압축공기·인터넷 요금 자동"), ("마감 리마인더", "D-25 역산 알림 — 인터넷은 현장 추가 불가"), ("배선 오버레이", "M5 이미지로 배선 확인"), ], item_size=9.5, gap=0.42) titled_card(s, 6.75, 1.5, 6.03, 3.15, "시각화 갤러리(SCR-12)", PURPLE, [ ("표준 샷 세트", "부스 정면·야간 조명·통로 뷰·홀 전경(S1~S7)"), ("Before/After", "설계 데이터 → 시공 후 예상 사진"), ("워터마크 상시", "모든 이미지에 ‘AI 생성 예상 이미지’ 고지"), ("컨펌 자료", "참가업체·장치업체 합의용으로 공유"), ("생성 대기", "완료 시 실시간 푸시로 알림"), ], item_size=9.5, gap=0.42) banner(s, 4.9, "AI 워터마크 고지 나노바나나 생성 이미지는 참고용 예상 이미지입니다 — 계약·심사·인허가 서류로 사용할 수 없습니다.", fill=RED, size=11.5, h=0.55) text(s, 0.55, 5.7, 12.2, 0.9, [ [{"t":"유틸리티 신청 팁 ","size":10.5,"color":INK,"bold":True}, {"t":"인터넷·전화·급배수·압축공기는 현장 추가신청이 어렵습니다. D-25 마감 알림을 놓치지 마세요. ","size":10,"color":MUTED}], [{"t":"전기 용량 ","size":10.5,"color":INK,"bold":True}, {"t":"과소 신청 시 현장 증설이 불가할 수 있어, 기기 목록 기반 자동 산출값을 확인하고 신청하세요.","size":10,"color":MUTED}], ], line_spacing=1.15, space_after=6) s = slide(); content_header(s, "03 · 공사 옥션 · 관람객", "옥션(견적·낙찰)과 관람객 등록 (M15·M10)", 3) steps(s, 0.55, 1.45, 12.23, [ ("① 옥션 개설", "설계자료 첨부", BLUE), ("② 응찰(견적서)", "업체 제출", PURPLE), ("③ 실시간 순위", "역경매", BLUE), ("④ 낙찰 비교", "동일사양 비교", AMBER), ("⑤ 선정·발주", "정산 연계", GREEN), ]) titled_card(s, 0.55, 2.5, 6.05, 2.75, "공사/장치 옥션(SCR-26~29)", BLUE, [ ("옥션 목록·개설", "AI 설계·물량·시공 예상 이미지를 응찰 근거로"), ("응찰·순위(업체)", "견적서 작성·제출 → 실시간 순위 확인"), ("낙찰 비교(발주자)", "동일 사양 위 공정 비교로 선정"), ("등록업체 게이트", "킨텍스 등록업체만 초대·응찰 가능"), ("발주 종결", "낙찰 → M9 정산·결제로 연계"), ], item_size=9.6, gap=0.44) titled_card(s, 6.75, 2.5, 6.03, 2.75, "관람객 등록·배지·리드(SCR-30~32)", PURPLE, [ ("사전등록", "공개 사이트 폼(SCR-P4)에서 관람 신청"), ("모바일 배지/QR", "관람객 앱에서 배지 발급·체크인"), ("현장 체크인", "데스크/키오스크(SCR-31)로 입장"), ("리드캡처", "참가업체가 배지 스캔으로 리드 수집(M4)"), ("리드 스코어링", "AI가 리드 우선순위 제안(SCR-32)"), ], item_size=9.6, gap=0.44) banner(s, 5.45, "승인 워크플로 제출물은 홀매니저 승인 큐 → 검수 상세 → 승인/반려를 거칩니다. 반려 시 사유와 위반 위치가 함께 전달됩니다.", fill=BLUE_DK, size=11, h=0.5) # ---- 04 모바일 ---- divider("04", "모바일 앱 사용법", "MOBILE APP", ["현장 체크리스트·검수", "시각화 갤러리", "오프라인·워터마크"]) s = slide(); content_header(s, "04 · 모바일 앱", "현장·조회·승인을 위한 모바일 앱", 4) text(s, 0.55, 1.4, 12.2, 0.35, "모바일 앱(Expo)은 조회·승인·현장 시나리오 전용입니다. 설계·배치 편집은 데스크톱 웹에서 수행합니다.", size=10.5, color=MUTED) titled_card(s, 0.55, 1.95, 3.95, 3.6, "현장(장치·홀매니저)", BLUE, [ ("로그인", "2FA/OTP·아이디 기억·비번찾기"), ("현장 체크리스트", "48px 큰 터치·오프라인·사진 첨부(SCR-M1)"), ("현장 검수", "승인/반려·AI 플래그 확인(SCR-M2)"), ("반입 통행증", "QR 통행증·안전 체크(SCR-M10)"), ("서류·승인 조회", "이동 중 확인(SCR-M12)"), ], item_size=9.6, gap=0.42) titled_card(s, 4.65, 1.95, 3.95, 3.6, "참가·관람", PURPLE, [ ("리드캡처", "배지 스캔으로 방문객 리드 수집(SCR-M4)"), ("관람객 배지/QR", "모바일 배지·체크인(SCR-M5)"), ("길찾기", "실내 지도·wayfinding(SCR-M6)"), ("부스 검색", "플로어플랜·부스 찾기(SCR-M7)"), ("비즈매칭", "미팅 요청·일정(SCR-M8·M9)"), ], item_size=9.6, gap=0.42) titled_card(s, 8.75, 1.95, 4.03, 3.6, "공통 · 유의", BLUE_DK, [ ("홈/대시보드", "역할별 진입(SCR-M3)"), ("시각화 갤러리", "워터마크·고지 항상 표시"), ("알림센터", "마감·승인·반려 푸시(SCR-M11)"), ("오프라인", "현장 통신 불안정 시 임시 저장"), ("보안", "비밀번호 저장 안 함 — 이메일만 기억"), ], item_size=9.6, gap=0.42) banner(s, 5.85, "설치 앱은 QR 코드 배포로 설치합니다(스토어 불필요). AI 생성 이미지는 앱에서도 워터마크·고지가 항상 표시됩니다.", fill=BLUE, size=11, h=0.5) # ---- 05 FAQ·문의 ---- divider("05", "FAQ · 문의", "FAQ & SUPPORT", ["자주 묻는 질문", "오류 대처", "지원 채널"]) s = slide(); content_header(s, "05 · FAQ", "자주 묻는 질문", 5) faqs = [ ("Q. 로그인 후 2차 인증코드를 못 받았어요.", "이메일 인증 방식은 스팸함을 확인하세요. OTP 방식은 인증 앱의 6자리 코드를 입력합니다. 기기 변경 시 마이페이지에서 재설정하거나 관리자에게 OTP 초기화를 요청하세요."), ("Q. AI가 만든 예상 사진을 계약서에 넣어도 되나요?", "안 됩니다. 모든 AI 이미지는 ‘예상 이미지’로 워터마크·고지가 붙으며 계약·인허가·심사 서류로 사용할 수 없습니다."), ("Q. 3안을 합쳐서 쓰고 싶어요.", "배치안 비교에서 여러 안의 구역/블록을 골라 병합할 수 있습니다. 병합 후에는 반드시 규정 검증을 다시 실행한 뒤 확정하세요."), ("Q. 유틸리티 신청 마감을 놓치면?", "인터넷·급배수 등은 현장 추가신청이 어렵습니다. D-25 역산 알림을 활용하고, 부득이한 경우 홀매니저/사무국에 문의하세요."), ("Q. 우리 장치업체를 옥션에 초대할 수 없어요.", "킨텍스 등록업체만 초대·응찰할 수 있습니다. 미등록 업체는 등록 절차 후 이용 가능합니다."), ("Q. 탭을 닫았는데 편집 내용이 사라졌어요.", "탭 세션은 복원되지만 서버에 저장하지 않은 편집 내용 자체는 복원되지 않습니다. 중요한 작업은 저장 후 닫으세요."), ] for i,(q,a) in enumerate(faqs): x = 0.55 + (i%2)*6.2 y = 1.55 + (i//2)*1.62 card(s, x, y, 6.05, 1.5) rect(s, x, y, 0.08, 1.5, fill=(BLUE if i%2==0 else PURPLE), shape=MSO_SHAPE.ROUNDED_RECTANGLE) text(s, x+0.24, y+0.14, 5.65, 0.5, q, size=10.5, color=INK, bold=True, line_spacing=0.95) text(s, x+0.24, y+0.66, 5.65, 0.78, a, size=9, color=MUTED, line_spacing=0.98) s = slide(); content_header(s, "05 · 문의 · 지원", "지원 채널 및 도움말", 5) titled_card(s, 0.55, 1.6, 6.05, 2.7, "이런 순서로 확인하세요", BLUE, [ "① 화면 안내(툴팁)·본 사용자지침서 해당 절 확인", "② 오류 배너의 요약 메시지·재시도 버튼 사용", "③ 마감·승인 관련은 알림센터에서 이력 확인", "④ 권한·계정 문제는 관리자(테넌트 관리자)에게", "⑤ 규정·현장 문제는 홀매니저·킨텍스 운영팀에", ], item_size=10, gap=0.44) titled_card(s, 6.75, 1.6, 6.03, 2.7, "역할별 지원 접점", PURPLE, [ ("참가업체", "설계·유틸리티 → 주최자 사무국"), ("장치·공사업체", "등록·옥션 → 킨텍스 등록업체 담당"), ("주최자", "배정·정산 → 킨텍스 행사지원팀"), ("관람객", "등록·배지 → 공개 사이트 문의(SCR-P6)"), ("공통", "계정·권한 → 시스템 관리자"), ], item_size=10, gap=0.44) banner(s, 4.55, "오류 메시지는 원인 요약만 표시됩니다(내부 정보 비노출). 반복되면 발생 시각·화면명·행사명을 함께 전달하면 빠르게 처리됩니다.", fill=BLUE_DK, size=11, h=0.6) text(s, 0.55, 5.5, 12.2, 0.8, "본 지침서의 화면 번호(SCR-*)는 설계 문서 docs/design.md의 화면 인벤토리(총 80화면)를 기준으로 합니다. 화면 구성은 업데이트로 달라질 수 있습니다.", size=9.5, color=MUTED, line_spacing=1.1) return PRS # ===================================================================== # 2) 운영자지침서 # ===================================================================== def build_ops(): global PRS, BLANK, DOC_FOOTER PRS = Presentation(); PRS.slide_width = EMU_W; PRS.slide_height = EMU_H BLANK = PRS.slide_layouts[6] DOC_FOOTER = "킨텍스 자동전시시스템 · 운영자지침서" cover("운영자지침서", "Operations Guide", ["킨텍스 자동전시시스템", "운영자 지침서"], "킨텍스 운영·관리자를 위한 운영·백오피스·장애 대응 안내서", "아키텍처 → 백오피스 → 배포 → 모니터링 → 백업·보안 → 정기 점검", [("대상", "테넌트(전시관) 관리자 · 플랫폼 슈퍼관리자 · 운영 담당"), ("근거", "OPS_RUNBOOK · SECURITY · BUILD_DEPLOY 준용")]) toc([ ("01", "운영 아키텍처 개요", "서비스 토폴로지 / 의존 관계"), ("02", "관리자 백오피스", "사용자·RBAC·감사·설정 / 마스터·룰셋·테넌트·AiConfig"), ("03", "배포 · CI/CD 운영", "push→자동배포 / health 게이트 / 롤백"), ("04", "모니터링 · 장애 대응", "헬스체크·로그·재기동 / 자주 겪는 이슈"), ("05", "백업 · 보안 점검", "보안 불변 / 워터마크 강제 / 자격증명 보호"), ("06", "정기 점검 체크리스트", "일 · 주 · 월 점검표"), ]) # ---- 01 아키텍처 ---- divider("01", "운영 아키텍처 개요", "OPERATIONS ARCHITECTURE", ["백엔드·워커·프론트", "PostgreSQL·Redis", "webhook 자동배포"]) s = slide(); content_header(s, "01 · 운영 아키텍처", "서비스 토폴로지 (개발 kintex.zioinfo.co.kr)", 1) def node(x,y,w,h,title,sub,fill,tcol,line=None): rect(s, x, y, w, h, fill=fill, line=(line or LINE), line_w=1.0, shape=MSO_SHAPE.ROUNDED_RECTANGLE) text(s, x, y+0.1, w, 0.3, title, size=11, color=tcol, bold=True, align=PP_ALIGN.CENTER) if sub: text(s, x, y+0.44, w, 0.28, sub, size=8.4, color=MUTED, align=PP_ALIGN.CENTER) # 프론트/nginx rect(s, 0.55, 1.55, 12.23, 0.44, fill=BLUE_LT, line=BLUE, shape=MSO_SHAPE.ROUNDED_RECTANGLE) text(s, 0.7, 1.55, 6, 0.44, "접속 계층 — nginx (kintex.service 도메인 :80/443)", size=10.5, color=BLUE_DK, bold=True, anchor=MSO_ANCHOR.MIDDLE) node(0.55, 2.15, 3.9, 0.8, "프론트(웹 정적)", "역할별 번들 · SPA 폴백", WHITE, INK, BLUE) node(4.65, 2.15, 3.9, 0.8, "백엔드 API", "kintex.service · REST + WebSocket", WHITE, INK, BLUE) node(8.75, 2.15, 4.03, 0.8, "나노바나나 워커", "kintex-nanobanana.service · 큐 소비", PURPLE_LT, PURPLE, PURPLE) # 데이터 rect(s, 0.55, 3.2, 12.23, 0.44, fill=HALL_BG, line=BLUE_DK, shape=MSO_SHAPE.ROUNDED_RECTANGLE) text(s, 0.7, 3.2, 6, 0.44, "데이터·큐 계층", size=10.5, color=BLUE_DK, bold=True, anchor=MSO_ANCHOR.MIDDLE) node(0.55, 3.8, 3.9, 0.8, "PostgreSQL + PostGIS", "kintex_db · 공간 데이터", WHITE, INK, BLUE_DK) node(4.65, 3.8, 3.9, 0.8, "Redis", "작업 큐·세션·캐시", WHITE, INK, BLUE_DK) node(8.75, 3.8, 4.03, 0.8, "오브젝트 스토리지", "렌더 이미지·문서", WHITE, INK, BLUE_DK) # 배포 rect(s, 0.55, 4.85, 12.23, 0.44, fill=PURPLE_LT, line=PURPLE, shape=MSO_SHAPE.ROUNDED_RECTANGLE) text(s, 0.7, 4.85, 6, 0.44, "배포·연동", size=10.5, color=PURPLE, bold=True, anchor=MSO_ANCHOR.MIDDLE) node(0.55, 5.45, 3.9, 0.8, "deploy_server (webhook)", "Gitea(zio/kintex) push 수신", WHITE, INK, PURPLE) node(4.65, 5.45, 3.9, 0.8, "Gemini (G1)", "워커 env only · 미승인시 degraded", WHITE, INK, PURPLE) node(8.75, 5.45, 4.03, 0.8, "헬스 게이트", "GET /health → data.status=UP", WHITE, INK, PURPLE) text(s, 0.55, 6.45, 12.2, 0.4, "의존 백엔드 → PostgreSQL·Redis · 워커 → Redis·Gemini(G1) · 프론트 → nginx → 백엔드. 헬스 판정 = /health UP + 두 서비스 active + Redis PONG + DB 응답.", size=9.3, color=MUTED) # ---- 02 백오피스 ---- divider("02", "관리자 백오피스", "ADMIN BACK-OFFICE", ["사용자·RBAC·감사·설정", "마스터데이터·룰셋", "테넌트 온보딩·AiConfig"]) s = slide(); content_header(s, "02 · 관리자 백오피스", "시스템관리 영역 (M18 · §5B)", 2) text(s, 0.55, 1.4, 12.2, 0.35, "백오피스(admin.)는 웹 전용입니다. 테넌트 관리자는 자기 전시관에 고정되고, 플랫폼 슈퍼관리자는 테넌트 스위처로 전 전시관을 통제합니다.", size=10.5, color=MUTED) titled_card(s, 0.55, 1.95, 3.95, 3.9, "시스템관리 (SCR-A1~A6)", BLUE, [ ("사용자 관리", "6역할 + 등록업체·관람객 계정(SCR-A1)"), ("역할·권한 RBAC", "플랫폼·행사 이중 평가(SCR-A2)"), ("공통코드", "홀·부스유형·공종14분류·요금(SCR-A3)"), ("메뉴 관리", "역할별 포털 IA 구동(SCR-A4)"), ("감사로그", "승인·낙찰·룰셋개정·리드접근(SCR-A5)"), ("시스템설정", "요율/규정 룰셋 버전(SCR-A6)"), ("잠금·OTP 초기화", "실패 잠금 해제·OTP 재설정"), ], item_size=9.4, gap=0.42) titled_card(s, 4.65, 1.95, 3.95, 3.9, "마스터·룰셋 (SCR-A7·A8)", PURPLE, [ ("마스터데이터", "홀·요율·요금·부스표준·등록업체(SCR-A7)"), ("규정 룰셋 버전", "높이·통로·바닥하중 규칙 개정(SCR-A8)"), ("룰셋은 데이터", "코드가 아닌 버전 파일로 관리"), ("개정 추적", "리포트에 rulesetVersion·고지 기록"), ("전시관 소유", "홀·요율·규정은 각 전시관이 소유"), ("근거 없는 추정 금지", "미확보 실측값은 온보딩 시 입력"), ], item_size=9.4, gap=0.42) titled_card(s, 8.75, 1.95, 4.03, 3.9, "테넌트·AI (SCR-A9·A10)", BLUE_DK, [ ("테넌트 관리", "전시관 마스터·온보딩(SCR-A9)"), ("테넌트 격리", "tenant_id로 전 데이터 스코프"), ("서브도메인", "kintex/coex 등 컨텍스트 해소"), ("KINTEX = 테넌트#1", "기존 데이터 백필 기준"), ("AiConfig", "AI 플랫폼·모델 설정(SCR-A10)"), ("Claude 기본", "실패 시 Ollama 자동 폴백"), ("크로스 집계", "전 테넌트 BI는 슈퍼관리자만"), ], item_size=9.4, gap=0.42) # ---- 03 배포 ---- divider("03", "배포 · CI/CD 운영", "DEPLOY & CI/CD", ["push → 자동배포", "health 게이트", "자동·수동 롤백"]) s = slide(); content_header(s, "03 · 배포 · CI/CD", "push → 자동배포 → 헬스 게이트", 3) steps(s, 0.55, 1.5, 12.23, [ ("① push", "scripts/push_kintex.py", BLUE), ("② webhook", "Gitea #47", PURPLE), ("③ 빌드", "bootJar · vite build", BLUE), ("④ 원자 교체", "deploy_kintex.sh", AMBER), ("⑤ health 게이트", "GET /health", GREEN), ]) titled_card(s, 0.55, 2.6, 6.05, 2.7, "정상 배포 경로(자동)", BLUE, [ ("git pull", "webhook 수신 후 소스 갱신"), ("Flyway 마이그", "스키마 전진 적용(전진만 권장)"), ("빌드", "프론트 vite build · 백엔드 gradlew bootJar"), ("jar 검증", "KintexApplication.class 존재 확인"), ("원자 교체", "무중단 교체 후 헬스체크"), ("검증", "kintex_smoke_test.py로 라우터 등록 확인"), ], item_size=9.6, gap=0.44) titled_card(s, 6.75, 2.6, 6.03, 2.7, "금지·주의", RED, [ ("동시 빌드 금지", "수동 Jenkins 추가 트리거 금지(jar 교체 깨짐)"), ("서버빌드 신뢰", "로컬 빌드 이슈보다 서버 빌드 결과 신뢰"), ("deploy_server 반영", "kintex 블록 서버 반영·zioinfo-deploy 재시작"), ("‘완료’ 오인", "로그 완료여도 1ms no-op면 블록 부재 의심"), ("빌드 OOM", "공유 8GB — 타 솔루션과 직렬 빌드"), ("운영 배포", "소유자 승인 필수"), ], item_size=9.6, gap=0.44) banner(s, 5.5, "롤백 deploy_kintex.sh는 헬스 실패 시 이전 jar를 자동 유지·복원합니다. 자동 실패 시 백업 jar 복원 또는 직전 정상 커밋으로 재배포합니다.", fill=BLUE_DK, size=11, h=0.5) # ---- 04 모니터링·장애 ---- divider("04", "모니터링 · 장애 대응", "MONITORING & INCIDENT", ["헬스체크 우선", "로그·재기동", "자주 겪는 이슈"]) s = slide(); content_header(s, "04 · 모니터링 · 장애", "헬스체크 · 로그 · 재기동 (런북 요약)", 4) titled_card(s, 0.55, 1.5, 6.05, 2.55, "점검 순서", BLUE, [ ("① 헬스체크", "kintex_smoke_test.py(자동) / /health(수동)"), ("② 서비스 상태", "kintex.service·kintex-nanobanana.service active"), ("③ 의존", "Redis PONG · DB SELECT 1"), ("④ 로그", "journalctl -u kintex.service -n 200"), ("⑤ 재기동", "systemctl restart → /health 재확인"), ], item_size=9.8, gap=0.46) titled_card(s, 6.75, 1.5, 6.03, 2.55, "재기동 원칙", PURPLE, [ ("백엔드", "restart 후 5초 뒤 /health 확인"), ("워커", "이미지 멈춤·큐 적체 시 워커만 재기동"), ("nginx", "정적 이상 시 nginx -t 후 reload"), ("DB/Redis", "공유 인스턴스 — 타 솔루션 영향 확인 후"), ("자동기동", "두 유닛 enable 상태 유지"), ], item_size=9.8, gap=0.46) # 이슈 표 text(s, 0.55, 4.25, 6, 0.3, "자주 겪는 이슈", size=12, color=INK, bold=True) rect(s, 0.55, 4.62, 12.23, 0.4, fill=BLUE, shape=MSO_SHAPE.ROUNDED_RECTANGLE) for x,w,t in [(0.55,4.2,"증상"),(4.75,3.6,"원인 후보"),(8.35,4.43,"조치")]: text(s, x+0.12, 4.62, w-0.15, 0.4, t, size=10, color=WHITE, bold=True, anchor=MSO_ANCHOR.MIDDLE) issues = [ ("/health 200인데 특정 API 404", "라우터 미배포(구 jar)", "스모크 러너로 라우터 확인 → 재배포"), ("500 relation ... does not exist", "마이그 미적용", "flyway info 확인, 보정 마이그(다음 번호) 추가"), ("이미지 생성 안 됨 / degraded", "G1 미승인·워커 다운", "워커 로그·env 확인, 워커 재기동(키는 워커 env only)"), ("배포 ‘완료’인데 반영 안 됨", "deploy_server 블록 부재", "서버 kintex 블록 반영·zioinfo-deploy 재시작"), ("push 했는데 자동배포 안 됨", "webhook 미설정/시크릿 불일치", "Gitea webhook #47 활성·secret·URL 확인"), ] ry=5.02; rh=0.4 for i,(sym,cause,act) in enumerate(issues): y=ry+i*rh if i%2==0: rect(s, 0.55, y, 12.23, rh, fill=RGBColor(0xFC,0xFC,0xFD)) rect(s, 0.55, y, 12.23, 0.008, fill=LINE) text(s, 0.67, y, 4.1, rh, sym, size=8.6, color=INK, anchor=MSO_ANCHOR.MIDDLE) text(s, 4.87, y, 3.5, rh, cause, size=8.6, color=MUTED, anchor=MSO_ANCHOR.MIDDLE) text(s, 8.35, y, 4.4, rh, act, size=8.6, color=BLUE_DK, anchor=MSO_ANCHOR.MIDDLE) # ---- 05 백업·보안 ---- divider("05", "백업 · 보안 점검", "BACKUP & SECURITY", ["보안 불변 원칙", "AI 워터마크 강제", "자격증명 보호"]) s = slide(); content_header(s, "05 · 백업 · 보안 점검", "보안 불변 및 배포·리뷰 전 점검", 5) titled_card(s, 0.55, 1.5, 6.05, 3.35, "보안 불변(위반 불가)", BLUE_DK, [ ("외부 API 통제", "온프레미스 우선 · Claude·Gemini만 예외"), ("키는 env only", "코드·DB·커밋·로그·응답 기록 금지"), ("Gemini는 워커만", "백엔드는 키 미취급(큐 발행·콜백만)"), ("자격증명 보호", "IP·SSH·비번·해시 응답/로그 미노출"), ("암호화 저장", "admin 비번 env 주입(AES-256-GCM)·하드코딩 금지"), ("스택트레이스 차단", "오류는 요약 메시지만 — 테이블/컬럼명 미노출"), ("AI 워터마크", "M5 이미지 응답 watermarkRequired 항상 포함"), ], item_size=9.4, gap=0.4) titled_card(s, 6.75, 1.5, 6.03, 3.35, "배포·리뷰 전 체크리스트", PURPLE, [ "응답/로그/커밋에 IP·SSH·비번·해시·API키 노출 0(grep)", "Gemini/Claude 키 env only — 코드·DB·커밋 미포함", "admin 비번 하드코딩 시드 없음(env 재시드)", "M5 이미지 응답 watermarkRequired:true 포함", "DB/서버 오류 → 요약 메시지(스택·relation 미노출)", "운영 스크립트 시크릿 하드코딩 0(마스킹)", "/api/admin/** ADMIN 게이트·미등록업체 차단 동작", ], item_size=9.4, gap=0.4) banner(s, 5.05, "백업·스키마 Flyway는 전진(forward)만 — 잘못된 마이그는 되돌리지 말고 보정 마이그를 추가합니다. 운영 DB 임의 롤백 금지.", fill=RED, size=11, h=0.55) text(s, 0.55, 5.8, 12.2, 0.8, [ [{"t":"로그에 자격증명·API키가 보이면 ","size":10.5,"color":INK,"bold":True}, {"t":"즉시 보안 이슈로 에스컬레이션합니다(SECURITY §2). 감사로그에도 비밀값을 기재하지 않습니다.","size":10,"color":MUTED}], ], line_spacing=1.1, space_after=4) # ---- 06 정기 점검 ---- divider("06", "정기 점검 체크리스트", "ROUTINE CHECKLIST", ["일간 점검", "주간 점검", "월간 점검"]) s = slide(); content_header(s, "06 · 정기 점검", "일 · 주 · 월 점검 체크리스트", 6) titled_card(s, 0.55, 1.5, 3.95, 4.6, "일간 (Daily)", BLUE, [ "스모크 러너 실행·전체 통과 확인", "/health UP · 두 서비스 active", "Redis PONG · DB SELECT 1", "워커 큐 소비·이미지 생성 정상", "에러 로그 급증·5xx 여부 확인", "마감 임박 알림(D-25·D-7) 발송 상태", "감사로그 이상 접근 스캔", ], item_size=9.6, gap=0.44) titled_card(s, 4.65, 1.5, 3.95, 4.6, "주간 (Weekly)", PURPLE, [ "배포 이력·롤백 발생 여부 검토", "Flyway 마이그 상태(info) 확인", "디스크·렌더 스토리지 용량 점검", "백업 정상 수행·복구 가능성 확인", "OTP·로그인 실패 잠금 문의 처리", "룰셋/요율 개정 요청 반영 검토", "webhook·시크릿 정합 점검", ], item_size=9.6, gap=0.44) titled_card(s, 8.75, 1.5, 4.03, 4.6, "월간 (Monthly)", BLUE_DK, [ "보안 불변 체크리스트 전수 점검(grep)", "권한(RBAC)·계정 접근 재검토", "감사로그 보존·이상 패턴 분석", "테넌트 격리·크로스 접근 점검", "AiConfig·모델 폴백 동작 확인", "의존 서비스(공유 DB/Redis) 용량 추이", "장애·변경 이력 회고 및 런북 갱신", ], item_size=9.6, gap=0.44) text(s, 0.55, 6.25, 12.2, 0.4, "점검 결과는 운영 기록으로 남기고, 이상 발견 시 OPS_RUNBOOK 절차(헬스→로그→재기동→롤백)로 대응합니다. 운영 배포·재구성은 소유자 승인이 필요합니다.", size=9.3, color=MUTED) return PRS # ===================================================================== # 3) 개발자지침서 # ===================================================================== def build_dev(): global PRS, BLANK, DOC_FOOTER PRS = Presentation(); PRS.slide_width = EMU_W; PRS.slide_height = EMU_H BLANK = PRS.slide_layouts[6] DOC_FOOTER = "킨텍스 자동전시시스템 · 개발자지침서" cover("개발자지침서", "Developer Guide", ["킨텍스 자동전시시스템", "개발자 지침서"], "개발 참여자를 위한 환경·표준·빌드·보안 가이드", "환경 구축 → 스택·구조 → 개발 표준 → 빌드·배포 → 하네스 → 보안 코딩", [("대상", "백엔드 · 프론트 · 모바일 · 워커 개발 참여자"), ("스택", "React(Vite)+TS · Spring Boot 3.x(Java17)+MyBatis · PostgreSQL(PostGIS) · Redis")]) toc([ ("01", "개발환경 구축", "clone · env(키 이름) · 로컬 실행"), ("02", "기술 스택 · 구조", "확정 스택 / backend·frontend·mobile·nanobanana"), ("03", "개발 표준", "레이어링·MyBatis·PostGIS / API 봉투·오류코드·토큰"), ("04", "빌드 · 테스트 · 배포", "게이트 / 테스트 / CI/CD 함정"), ("05", "하네스 · 에이전트 워크플로", "planner→designer→dev→qa→devops"), ("06", "보안 코딩 규칙", "보안 불변 · 워터마크 · 자격증명 보호"), ]) # ---- 01 환경 ---- divider("01", "개발환경 구축", "DEV ENVIRONMENT", ["도구·clone", "DB·Redis·워커", "env 키 이름만"]) s = slide(); content_header(s, "01 · 개발환경 구축", "사전 도구 · clone · 로컬 실행", 1) titled_card(s, 0.55, 1.5, 6.05, 3.0, "사전 요구 도구", BLUE, [ ("JDK 17 (LTS)", "Spring Boot 3.x 빌드/실행(gradlew)"), ("Node.js 18+", "Vite 빌드(Node 16 불가 — WISE 함정)"), ("PostgreSQL 15+/16", "kintex_db"), ("PostGIS 3.x", "부스 polygon·트렌치 point·배선 LineString"), ("Redis 6+/7", "작업 큐(RenderJob·서류·알림)"), ("Python 3.11+", "나노바나나 워커 사이드카"), ], item_size=9.6, gap=0.42) titled_card(s, 6.75, 1.5, 6.03, 3.0, "초기 세팅 흐름", PURPLE, [ ("clone", "git clone /zio/kintex.git"), ("백엔드", "src/backend → ./gradlew build"), ("프론트", "src/frontend → npm install"), ("워커", "tools/nanobanana → pip install google-genai Pillow"), ("DB 준비", "kintex_db + CREATE EXTENSION postgis"), ("스키마", "Flyway 순번 마이그레이션으로 적용"), ], item_size=9.6, gap=0.42) banner(s, 4.7, "환경변수는 이름(키)만 규약입니다 — 실제 비밀값·포트는 저장소에 두지 않고 사내 비밀관리·env에서 주입합니다.", fill=BLUE_DK, size=11, h=0.5) text(s, 0.55, 5.35, 12.2, 1.05, [ [{"t":"백엔드 env 키(이름만) ","size":10,"color":INK,"bold":True}, {"t":"KINTEX_DB_PASSWORD · KINTEX_JWT_SECRET · REDIS_HOST/REDIS_PORT · RENDER_WORKER_TOKEN · ANTHROPIC_API_KEY · ADMIN_PASSWORD_ENC/ADMIN_KEY_FILE · SMTP_*","size":9.3,"color":MUTED}], [{"t":"워커 env 키(이름만) ","size":10,"color":INK,"bold":True}, {"t":"GEMINI_API_KEY(워커 전용·G1 승인 대상) · NANOBANANA_MODEL(기본 gemini-3.1-flash-image-preview). 값은 코드·커밋·로그에 기록 금지.","size":9.3,"color":MUTED}], [{"t":"원격 DB ","size":10,"color":INK,"bold":True}, {"t":"5432 외부 차단 시 SSH 로컬 포워딩으로 jdbc:postgresql://localhost:5432/kintex_db 접속.","size":9.3,"color":MUTED}], ], line_spacing=1.12, space_after=5) # ---- 02 스택·구조 ---- divider("02", "기술 스택 · 구조", "STACK & STRUCTURE", ["확정 스택(변경 금지)", "패키지 레이어", "프론트·모바일·워커"]) s = slide(); content_header(s, "02 · 기술 스택", "확정 스택 (변경 금지)", 2) rect(s, 0.55, 1.55, 12.23, 0.44, fill=BLUE, shape=MSO_SHAPE.ROUNDED_RECTANGLE) for x,w,t in [(0.55,3.0,"레이어"),(3.55,9.23,"표준")]: text(s, x+0.15, 1.55, w-0.2, 0.44, t, size=10.5, color=WHITE, bold=True, anchor=MSO_ANCHOR.MIDDLE) stack = [ ("프론트(웹)", "React 18/19 + Vite + TypeScript — 역할별 번들 분리(§2-1)"), ("백엔드", "Spring Boot 3.x(Java 17) + MyBatis — REST + WebSocket(STOMP)"), ("DB", "PostgreSQL + PostGIS(부스 polygon·트렌치 point·배선 LineString)"), ("비동기", "Redis 작업 큐(RenderJob·서류·알림)"), ("이미지 생성", "나노바나나 Python 워커 사이드카(tools/nanobanana, google-genai)"), ("AI(텍스트)", "Claude 기본 + 설정형 전환(AiTextRouter/AiConfig) — 실패 시 Ollama 폴백"), ("인증", "행사 단위 RBAC(JWT HS256) + 2차 인증(OTP/TOTP)"), ] ry=2.05; rh=0.52 for i,(k,v) in enumerate(stack): y=ry+i*rh if i%2==0: rect(s, 0.55, y, 12.23, rh, fill=RGBColor(0xFC,0xFC,0xFD)) rect(s, 0.55, y, 12.23, 0.008, fill=LINE) text(s, 0.7, y, 2.9, rh, k, size=10, color=BLUE_DK, bold=True, anchor=MSO_ANCHOR.MIDDLE) text(s, 3.6, y, 9.1, rh, v, size=9.6, color=INK, anchor=MSO_ANCHOR.MIDDLE) banner(s, 5.8, "패키지 루트 com.zioinfo.kintex · DB kintex_db · 나노바나나 호출은 tools/nanobanana 워커만(백엔드는 GEMINI_API_KEY 미취급).", fill=BLUE_DK, size=10.5, h=0.5) s = slide(); content_header(s, "02 · 프로젝트 구조", "백엔드 레이어 · 프론트 · 모바일 · 워커", 2) titled_card(s, 0.55, 1.5, 6.05, 4.0, "백엔드 (com.zioinfo.kintex)", BLUE, [ ("config", "Security·Jwt·WebSocket·Redis·@MapperScan"), ("security", "JwtTokenProvider·Filter·KintexPrincipal"), ("common", "response(ApiResponse·PageResponse)·exception·audit"), ("auth", "AuthService·TotpService·mapper·dto"), ("module/m2~m5", "플로어플랜·부스설계·배선·나노바나나"), ("module/m10·m15·m16·m18", "도메인(Phase D)"), ("system", "사용자·권한·공통코드·메뉴·감사·설정"), ("work", "worklog·schedule·message·stats·notice…"), ], item_size=9.2, gap=0.4) titled_card(s, 6.75, 1.5, 6.03, 4.0, "프론트 · 모바일 · 워커", PURPLE, [ ("src/frontend", "pages·components·api·store·hooks·routes"), ("역할별 번들", "organizer·exhibitor·contractor·ops·admin·public"), ("공유 상속", "디자인 시스템·공통 컴포넌트·API 계약 상속"), ("axios", "baseURL=/api · 응답 타입 = 계약 shape 1:1"), ("mobile/", "Expo SDK 51·expo-router·RN 0.74·TS"), ("모바일 저장", "JWT는 expo-secure-store(웹 AsyncStorage)"), ("tools/nanobanana", "큐 소비 데몬 → 콜백(RENDER_WORKER_TOKEN)"), ("룰셋은 데이터", "rulesets/*.json 버전 파일(코드 아님)"), ], item_size=9.2, gap=0.4) # ---- 03 개발 표준 ---- divider("03", "개발 표준", "DEVELOPMENT STANDARDS", ["레이어링·MyBatis·PostGIS", "API 봉투·오류코드", "디자인 토큰·네이밍"]) s = slide(); content_header(s, "03 · 개발 표준", "레이어링 · API 계약 · 코딩 규약", 3) titled_card(s, 0.55, 1.5, 6.05, 3.4, "백엔드 레이어링·MyBatis", BLUE, [ ("계층 규칙", "controller(검증·RBAC) → service(트랜잭션·룰) → mapper"), ("컨트롤러", "도메인 로직 금지"), ("매퍼", "비즈니스 판단 금지 · @Mapper + XML"), ("PostGIS", "ST_MakePolygon·ST_Area·ST_Distance·<-> KNN은 XML"), ("룰셋", "규정·요율은 버전 파일 · rulesetVersion·disclaimer 기록"), ("네이밍", "Java camel/Pascal · DB SNAKE_CASE · DTO camelCase"), ("마이그레이션", "Flyway 순번 · 번호 충돌 금지(최대+1)"), ], item_size=9.3, gap=0.4) titled_card(s, 6.75, 1.5, 6.03, 3.4, "API·응답 규약", PURPLE, [ ("응답 봉투", "ApiResponse = { success, data, error }"), ("목록", "PageResponse = { items, page, size, total }"), ("오류코드", "VALIDATION 400·FORBIDDEN 403·COMPLIANCE_BLOCKED 422"), ("미구현", "NOT_IMPLEMENTED 501"), ("스코프", "도메인 경로는 {eventId} + 행사 RBAC 가드"), ("프론트 타입", "TS strict · 응답 타입 = 계약 shape 1:1 · any 지양"), ("코드그룹", "boothType·myRole·severity는 COMMON_CODES 단일 출처"), ], item_size=9.3, gap=0.4) banner(s, 5.1, "디자인 토큰 색·타이포·간격은 design.md §1 토큰만 사용(신규 토큰 추가 금지). 데이터 시각화는 CSS 변수 주입(하드코딩 hex 금지).", fill=BLUE_DK, size=11, h=0.5) text(s, 0.55, 5.8, 12.2, 0.7, "인증 1차 로그인(ID/PW) → verifyToken → 2차 검증(EMAIL/OTP TOTP RFC6238) → access·refresh. 로그인 실패 잠금 + 관리자 해제. /api/system·/api/admin = hasRole(ADMIN).", size=9.5, color=MUTED, line_spacing=1.1) # ---- 04 빌드·테스트·배포 ---- divider("04", "빌드 · 테스트 · 배포", "BUILD · TEST · DEPLOY", ["push 전 게이트", "단위·경계면·보안 회귀", "CI/CD 함정"]) s = slide(); content_header(s, "04 · 빌드 · 테스트 · 배포", "게이트 · 테스트 · 파이프라인", 4) steps(s, 0.55, 1.5, 12.23, [ ("① compile/tsc", "변경분 통과", BLUE), ("② 시크릿 차단", ".env·*.key 금지", PURPLE), ("③ 마이그 번호", "충돌 검사", BLUE), ("④ push", "= 배포 트리거", AMBER), ("⑤ health 게이트", "실패 시 롤백", GREEN), ]) titled_card(s, 0.55, 2.6, 6.05, 2.75, "테스트", BLUE, [ ("백엔드 단위", "룰 엔진(규정 평가·요율 산식·배선 최단경로)"), ("공간 쿼리", "PostGIS 통합(testcontainers/로컬 PostGIS)"), ("경계면(계약)", "API 응답 shape ↔ 프론트 훅/컴포넌트 교차 대조"), ("보안 회귀", "자격증명·PII·스택트레이스·워터마크·응찰 가드"), ("워커", "키/네트워크 없이도 import·구조 성립(목/degraded)"), ], item_size=9.5, gap=0.44) titled_card(s, 6.75, 2.6, 6.03, 2.75, "빌드·배포 함정", RED, [ ("Node 16 금지", "vite build 불가 — Node 18+ 사용"), ("서버빌드 신뢰", "로컬 rollup/win32 크래시는 서버 빌드 신뢰"), ("동시 빌드 금지", "target/*.jar 교체 깨짐 — 직렬 빌드"), ("자기방어 스크립트", "jar 검증 → 원자 교체 → 헬스 → 실패 시 롤백"), ("커밋 시점", "지시가 있을 때만 · 운영 배포 소유자 승인"), ], item_size=9.5, gap=0.44) banner(s, 5.6, "Flyway는 전진만 — 잘못된 마이그는 되돌리지 말고 보정 마이그(다음 번호)를 추가합니다. baseline-on-migrate=true.", fill=AMBER, size=11, h=0.5) # ---- 05 하네스 ---- divider("05", "하네스 · 에이전트 워크플로", "HARNESS & AGENTS", ["기획→디자인→구현", "점진 QA", "배포 오케스트레이션"]) s = slide(); content_header(s, "05 · 하네스 · 에이전트", "에이전트 파이프라인 개요", 5) steps(s, 0.55, 1.55, 12.23, [ ("기획", "planner", PURPLE), ("설계", "AA/SA/TA/DA/NA", BLUE), ("디자인", "designer", PURPLE), ("구현", "backend·front·db·domain", BLUE), ("검증", "kintex-qa", GREEN), ("배포", "devops-dev", BLUE_DK), ]) titled_card(s, 0.55, 2.65, 6.05, 2.7, "문서 소유권·경유", BLUE, [ ("PLANNING.md", "범위 권위 — planner만 수정"), ("design.md", "화면 스펙 — designer 경유"), ("src/", "PLANNING·design 근거로 developer 구현"), ("나노바나나", "tools/nanobanana만 경유(visualizer)"), ("오케스트레이터", "kintex-impl / kintex-mobile 스킬로 조율"), ], item_size=9.6, gap=0.44) titled_card(s, 6.75, 2.65, 6.03, 2.7, "품질 게이트(점진 QA)", PURPLE, [ ("모듈 완성 직후", "경계면·공간 로직·보안 불변·빌드 회귀"), ("경계면", "백엔드 shape ↔ 프론트 호출 교차 비교"), ("보안 불변", "자격증명·PII·스택트레이스 미노출 강제"), ("AI 워터마크", "이미지 워터마크·응찰 게이트 검증"), ("반려 기준", "compileJava·tsc 통과까지 반려"), ], item_size=9.6, gap=0.44) banner(s, 5.6, "브랜치 main 보호 · Conventional Commits(feat/fix/docs…) · 코드 식별자·커밋 제목은 영어, 설명은 한국어 허용.", fill=BLUE_DK, size=11, h=0.5) # ---- 06 보안 코딩 ---- divider("06", "보안 코딩 규칙", "SECURE CODING", ["보안 불변(위반 시 반려)", "AI 워터마크", "자격증명·오류 응답"]) s = slide(); content_header(s, "06 · 보안 코딩 규칙", "보안 불변 (위반 시 QA 반려)", 6) titled_card(s, 0.55, 1.5, 6.05, 3.9, "지켜야 할 규칙", BLUE_DK, [ ("자격증명 미노출", "IP·SSH·비번·해시·API키·OTP 시크릿 미노출"), ("민감 필드 제외", "사용자/업체 응답은 비민감 필드만"), ("스택트레이스 차단", "error.message는 요약만 · 상세는 서버 로그"), ("AI 이미지 워터마크", "watermarkRequired+watermarkText+notice 항상"), ("등록업체 응찰", "미등록 CONTRACTOR NOT_REGISTERED_COMPANY 403"), ("외부 API 금지", "예외 = Claude(env·Ollama 폴백)·Gemini(워커·G1)"), ("암호화 저장", "비밀 AES-256-GCM · 비밀번호 BCrypt 해시"), ], item_size=9.3, gap=0.4) titled_card(s, 6.75, 1.5, 6.03, 3.9, "코드에서 이렇게", PURPLE, [ ("키 로드", "env에서만 — 코드·DB·커밋·로그 기록 금지"), ("Gemini", "워커에서만 로드 · 백엔드는 큐 발행/콜백만"), ("admin 비번", "ADMIN_PASSWORD_ENC 복호 → 기동 시 BCrypt 재시드"), ("오류 핸들러", "GlobalExceptionHandler·DataAccessException로 누출 차단"), ("gitignore", ".env·*.key·*-adminsdk-*.json 커밋 차단"), ("워커 실패", "errorMessage는 요약만(스택 유입 차단)"), ("감사로그", "민감 액션 기록 · 비밀값 미기재"), ], item_size=9.3, gap=0.4) banner(s, 5.6, "체크(grep) — 응답·로그·커밋에 IP·SSH·비번·해시·API키·워커토큰 노출 0. 위반은 QA 반려 사유입니다.", fill=RED, size=11, h=0.5) return PRS # ===================================================================== # 저장 # ===================================================================== if __name__ == "__main__": out_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) # ...\지침서 targets = [ ("사용자지침서.pptx", build_user), ("운영자지침서.pptx", build_ops), ("개발자지침서.pptx", build_dev), ] for fname, fn in targets: prs = fn() path = os.path.join(out_dir, fname) prs.save(path) print(f"SAVED: {path} (slides={len(prs.slides._sldIdLst)})")