""" GUARDiA ITSM 온보딩 가이드 API 설치 완료 후 자동 시작되어 사용자를 단계별로 안내. 엔드포인트: GET /api/onboarding/status — 현재 온보딩 상태 POST /api/onboarding/step — 단계 업데이트 POST /api/onboarding/message — 현재 화면 기반 봇 응답 POST /api/onboarding/complete — 온보딩 완료 처리 POST /api/onboarding/reset — 온보딩 초기화 (재시작) GET /api/onboarding/steps — 전체 단계 목록 """ from __future__ import annotations import logging from datetime import datetime from typing import Optional from fastapi import APIRouter, Depends, HTTPException from pydantic import BaseModel from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from core.auth import get_current_user from database import get_db from models import User logger = logging.getLogger(__name__) router = APIRouter(prefix="/api/onboarding", tags=["onboarding"]) # ── 온보딩 단계 정의 ───────────────────────────────────────────────────────── ONBOARDING_STEPS = [ { "id": "welcome", "order": 0, "title": "환영합니다!", "icon": "👋", "view": None, # 어느 화면에서나 "target": None, # 하이라이트할 요소 "message": ( "안녕하세요! GUARDiA ITSM에 오신 것을 환영합니다.\n\n" "설치가 완료되었습니다. 지금부터 **5단계** 가이드로\n" "첫 번째 프로젝트를 등록하고 소스코드 관리까지 완료해 드리겠습니다.\n\n" "준비가 되셨으면 **시작하기**를 눌러주세요." ), "actions": [{"label": "시작하기 →", "action": "next"}], }, { "id": "change_password", "order": 1, "title": "비밀번호 변경", "icon": "🔑", "view": "/change-password", "target": "#current-password", "message": ( "보안을 위해 **초기 비밀번호를 변경**해 주세요.\n\n" "초기 비밀번호: `1111`\n\n" "현재 비밀번호에 `1111`을 입력하고,\n" "새로운 안전한 비밀번호로 변경해 주세요.\n\n" "💡 **강력한 비밀번호 조건**\n" "- 8자 이상\n- 대문자 + 숫자 + 특수문자 포함" ), "actions": [ {"label": "비밀번호 변경 페이지로", "action": "navigate", "path": "/change-password"}, {"label": "이미 변경했어요", "action": "next"}, ], }, { "id": "explore_dashboard", "order": 2, "title": "대시보드 둘러보기", "icon": "📊", "view": "/", "target": ".dash-tab-nav", "message": ( "이곳이 GUARDiA **통합 대시보드**입니다.\n\n" "4가지 탭으로 구성되어 있어요:\n" "- 📊 **운영 현황** — SR·SLA·이슈 현황\n" "- 🖥️ **인프라** — 서버·CMDB 현황\n" "- 🔒 **보안** — 취약점·패치 현황\n" "- 🤖 **AI 인사이트** — 예측·이상탐지\n\n" "각 탭을 클릭해서 살펴보세요!" ), "actions": [ {"label": "대시보드로 이동", "action": "navigate", "path": "/"}, {"label": "다음 단계 →", "action": "next"}, ], }, { "id": "create_project", "order": 3, "title": "첫 프로젝트 등록", "icon": "🏗️", "view": "/si", "target": ".btn-primary", "message": ( "**PMS(프로젝트 관리)**에서 첫 번째 프로젝트를 등록해보세요.\n\n" "**1.** 좌측 메뉴 → **PMS 프로젝트** 클릭\n" "**2.** 우측 상단 **+ 프로젝트 생성** 버튼 클릭\n" "**3.** 아래 정보를 입력하세요:\n\n" "```\n프로젝트 코드: PRJ-2026-001\n프로젝트명: 우리 회사 정보화사업\n담당 PM: admin\n```\n\n" "등록하면 WBS, 산출물, 보고서 기능을 바로 사용할 수 있어요." ), "actions": [ {"label": "프로젝트 관리로 이동", "action": "navigate", "path": "/si"}, {"label": "프로젝트 등록 완료", "action": "next"}, ], }, { "id": "register_server", "order": 4, "title": "관리 서버 등록", "icon": "🖥️", "view": "/", "target": "[data-view='cmdb']", "message": ( "**CMDB**에 관리할 서버를 등록해 주세요.\n\n" "**1.** 좌측 메뉴 → **인프라 → CMDB** 클릭\n" "**2.** **서버 등록** 버튼 클릭\n" "**3.** 아래 정보를 입력하세요:\n\n" "```\n서버명: web-server-01\nIP: 10.0.0.1\nOS: CentOS 7\nSSH 계정: opsagent\n```\n\n" "⚠️ 보안: SSH 비밀번호는 AES-256-GCM으로 암호화 저장됩니다.\n\n" "서버 등록 후 메신저 봇으로 즉시 제어할 수 있어요." ), "actions": [ {"label": "CMDB로 이동", "action": "navigate", "path": "/?view=cmdb"}, {"label": "나중에 등록", "action": "next"}, {"label": "서버 등록 완료", "action": "next"}, ], }, { "id": "register_source", "order": 5, "title": "소스 코드 등록", "icon": "📦", "view": None, "target": None, "message": ( "**Gitea**에 소스 코드를 등록하세요.\n\n" "Gitea는 설치 시 자동으로 구성되었습니다.\n\n" "**1.** Gitea 접속: `http://[서버IP]:3000`\n" "**2.** 계정: `gitadmin / Gitea@guardia!`\n" "**3.** guardia 조직 → GUARDiA 저장소에 소스 Push:\n\n" "```bash\ngit remote add guardia \\\n http://gitadmin@[서버IP]:3000/guardia/GUARDiA.git\ngit push guardia main\n```\n\n" "등록 후 Jenkins CI/CD와 자동 연동됩니다." ), "actions": [ {"label": "Gitea 열기", "action": "external", "url": "http://localhost:3000"}, {"label": "소스 등록 완료", "action": "next"}, ], }, { "id": "setup_messenger", "order": 6, "title": "메신저 봇 연결", "icon": "💬", "view": None, "target": None, "message": ( "**메신저 봇**을 연결하면 채팅으로 인프라를 제어할 수 있어요.\n\n" "지원 플랫폼:\n" "- 카카오워크\n- 네이버웍스\n- 슬랙\n\n" "**설정 방법:**\n" "`.env` 파일에 아래를 추가:\n\n" "```\nMESSENGER_BASE_URL=http://메신저서버:8002\nBOT_SEND_API=http://봇API주소\n```\n\n" "설정 후 `/sr 테스트 메시지`로 동작을 확인하세요.\n\n" "📌 봇 명령어 25개: `/sr`, `/deploy`, `/rca`, `/scan` 등" ), "actions": [ {"label": "나중에 설정", "action": "next"}, {"label": "설정 완료", "action": "next"}, ], }, { "id": "complete", "order": 7, "title": "설정 완료!", "icon": "🎉", "view": None, "target": None, "message": ( "**축하합니다!** GUARDiA ITSM 초기 설정이 완료되었습니다.\n\n" "✅ 비밀번호 변경\n" "✅ 프로젝트 등록\n" "✅ 서버 등록\n" "✅ 소스 코드 등록\n" "✅ 메신저 봇 연결\n\n" "이제 GUARDiA의 모든 기능을 사용할 수 있습니다!\n\n" "도움이 필요하면 우측 하단 **?** 버튼을 클릭하거나\n" "봇 명령어 `/help`를 입력하세요." ), "actions": [ {"label": "완료 — 시작하기!", "action": "complete"}, ], }, ] # ── 온보딩 상태 (운영 시 DB 테이블로 이전) ───────────────────────────────── # 사용자별 상태 저장 _onboarding_state: dict[str, dict] = {} def _get_user_state(username: str) -> dict: if username not in _onboarding_state: _onboarding_state[username] = { "username": username, "current_step": 0, "completed": False, "dismissed": False, "started_at": datetime.utcnow().isoformat(), "completed_at": None, "step_history": [], } return _onboarding_state[username] # ── 스키마 ─────────────────────────────────────────────────────────────────── class StepUpdateRequest(BaseModel): step_id: str action: str = "complete" # complete | skip | back class MessageRequest(BaseModel): current_view: str = "/" current_step: Optional[str] = None user_message: Optional[str] = None # ── 엔드포인트 ─────────────────────────────────────────────────────────────── @router.get("/status") async def get_onboarding_status( cu: User = Depends(get_current_user), ): """현재 온보딩 상태 반환.""" state = _get_user_state(cu.username) step_idx = state["current_step"] current = ONBOARDING_STEPS[step_idx] if step_idx < len(ONBOARDING_STEPS) else None return { "username": cu.username, "current_step": step_idx, "total_steps": len(ONBOARDING_STEPS), "completed": state["completed"], "dismissed": state["dismissed"], "show_bot": not state["completed"] and not state["dismissed"], "current": current, "progress_pct": round(step_idx / len(ONBOARDING_STEPS) * 100, 0), } @router.get("/steps") async def get_steps(_u: User = Depends(get_current_user)): """전체 온보딩 단계 목록.""" return {"steps": ONBOARDING_STEPS, "total": len(ONBOARDING_STEPS)} @router.post("/step") async def update_step( body: StepUpdateRequest, cu: User = Depends(get_current_user), ): """단계 업데이트 (완료/건너뜀/뒤로).""" state = _get_user_state(cu.username) current_idx = state["current_step"] if body.action in ("complete", "next", "skip"): if current_idx < len(ONBOARDING_STEPS) - 1: state["current_step"] = current_idx + 1 state["step_history"].append({ "step_id": body.step_id, "action": body.action, "at": datetime.utcnow().isoformat(), }) elif body.action == "back" and current_idx > 0: state["current_step"] = current_idx - 1 new_idx = state["current_step"] new_step = ONBOARDING_STEPS[new_idx] if new_idx < len(ONBOARDING_STEPS) else None return { "current_step": new_idx, "step": new_step, "completed": state["completed"], } @router.post("/complete") async def complete_onboarding(cu: User = Depends(get_current_user)): """온보딩 완료 처리.""" state = _get_user_state(cu.username) state["completed"] = True state["current_step"] = len(ONBOARDING_STEPS) - 1 state["completed_at"] = datetime.utcnow().isoformat() return {"message": "온보딩 완료!", "completed": True} @router.post("/dismiss") async def dismiss_onboarding(cu: User = Depends(get_current_user)): """온보딩 임시 숨기기.""" state = _get_user_state(cu.username) state["dismissed"] = True return {"dismissed": True} @router.post("/reset") async def reset_onboarding(cu: User = Depends(get_current_user)): """온보딩 초기화 (재시작).""" _onboarding_state.pop(cu.username, None) return {"message": "온보딩 초기화 완료 — 새로고침하면 다시 시작됩니다."} @router.post("/message") async def get_bot_message( body: MessageRequest, cu: User = Depends(get_current_user), ): """현재 화면 + 사용자 메시지 기반 봇 응답 (Ollama 연동).""" state = _get_user_state(cu.username) # 현재 단계 메시지 반환 (기본) step_idx = state["current_step"] current = ONBOARDING_STEPS[step_idx] if step_idx < len(ONBOARDING_STEPS) else None # 사용자 질문이 있으면 Ollama로 답변 if body.user_message: try: from core.llm_client import get_llm_client context = ( f"당신은 GUARDiA ITSM 설치 가이드 AI입니다. " f"현재 사용자는 '{current['title'] if current else '설치 완료'}' 단계에 있습니다. " f"현재 화면: {body.current_view}\n" f"사용자 질문: {body.user_message}\n\n" f"간결하고 친절하게 한국어로 답변하세요. " f"GUARDiA API나 기능에 대해 모르면 솔직히 말하세요." ) client = get_llm_client() resp = await client.chat(context) return { "type": "llm_answer", "message": resp.content.strip()[:500], "current": current, } except Exception as e: logger.debug("Ollama 응답 실패: %s", e) # 화면별 컨텍스트 힌트 view_hints = { "/": "대시보드 화면입니다. 좌측 메뉴에서 다양한 기능에 접근할 수 있어요.", "/change-password":"비밀번호 변경 화면입니다. 현재 비밀번호 1111을 새 비밀번호로 변경하세요.", "/si": "PMS 프로젝트 관리 화면입니다. + 버튼으로 새 프로젝트를 만드세요.", "/incidents": "인시던트 관리 화면입니다. 장애 발생 시 여기서 처리하세요.", "/agents": "AI 에이전트 현황입니다. Ollama 연결 상태를 확인할 수 있어요.", "/license": "라이선스 관리 화면입니다. 현재 체험판 라이선스가 적용되어 있어요.", } hint = view_hints.get(body.current_view, "") return { "type": "step_guide", "message": current["message"] if current else "온보딩이 완료되었습니다!", "hint": hint, "current": current, "step_idx":step_idx, "progress":round(step_idx / len(ONBOARDING_STEPS) * 100, 0), }