From e7dc273b36906806178bfd61c423ef7ebde71163 Mon Sep 17 00:00:00 2001 From: "DESKTOP-TKLFCPR\\ython" Date: Tue, 2 Jun 2026 00:49:33 +0900 Subject: [PATCH] =?UTF-8?q?feat(expansion):=20GUARDiA=20v3=20=E2=80=94=206?= =?UTF-8?q?=20P1=20routers=20+=207=20DB=20tables?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 라우터 (584개 엔드포인트, 신규 39개): - rag_engine.py: 하이브리드 RAG 검색 (BM25+pgvector+RRF) + Ollama 답변 - jira_sync.py: Jira 양방향 SR 동기화 + 웹훅 수신 - kpi_engine.py: KPI 정의·계산·신호등 + 내장 5개 템플릿 - tenant_portal.py: 테넌트 셀프서비스 포털 + 사용자 초대 - bi_dashboard.py: BI 대시보드 (트렌드·히트맵·퍼널·MTTR) - autonomous_workflow.py: 조건 기반 자율 워크플로우 엔진 DB 모델 (7개 신규 테이블): tb_rag_feedback, tb_jira_config, tb_jira_sync_mapping, tb_kpi_definition, tb_kpi_value, tb_auto_workflow_rule, tb_auto_workflow_run Co-Authored-By: Claude Sonnet 4.6 --- workspace/guardia-itsm/main.py | 9 + workspace/guardia-itsm/models.py | 111 +++++ .../routers/autonomous_workflow.py | 394 +++++++++++++++++ .../guardia-itsm/routers/bi_dashboard.py | 289 +++++++++++++ workspace/guardia-itsm/routers/jira_sync.py | 375 ++++++++++++++++ workspace/guardia-itsm/routers/kpi_engine.py | 404 ++++++++++++++++++ workspace/guardia-itsm/routers/rag_engine.py | 303 +++++++++++++ .../guardia-itsm/routers/tenant_portal.py | 333 +++++++++++++++ 8 files changed, 2218 insertions(+) create mode 100644 workspace/guardia-itsm/routers/autonomous_workflow.py create mode 100644 workspace/guardia-itsm/routers/bi_dashboard.py create mode 100644 workspace/guardia-itsm/routers/jira_sync.py create mode 100644 workspace/guardia-itsm/routers/kpi_engine.py create mode 100644 workspace/guardia-itsm/routers/rag_engine.py create mode 100644 workspace/guardia-itsm/routers/tenant_portal.py diff --git a/workspace/guardia-itsm/main.py b/workspace/guardia-itsm/main.py index b7635320..8d67f6b9 100644 --- a/workspace/guardia-itsm/main.py +++ b/workspace/guardia-itsm/main.py @@ -307,6 +307,15 @@ app.include_router(autonomous.router) # 자율 운영 (자동처리/승인 app.include_router(rpa.router) # RPA 봇 (Validation 학습 + 자동화 실행) app.include_router(scraping.router) # 스크랩핑 봇 (URL 수집 + 게시/삭제/원복) +# ── GUARDiA 확장 v3 (2026-06-02) ───────────────────────────────────────────── +from routers import rag_engine, jira_sync, kpi_engine, tenant_portal, bi_dashboard, autonomous_workflow +app.include_router(rag_engine.router) # RAG 하이브리드 검색 + Ollama 답변 +app.include_router(jira_sync.router) # Jira 양방향 SR 동기화 +app.include_router(kpi_engine.router) # KPI 정의·계산·신호등 +app.include_router(tenant_portal.router) # 테넌트 셀프서비스 포털 +app.include_router(bi_dashboard.router) # BI 대시보드 (트렌드·히트맵·퍼널) +app.include_router(autonomous_workflow.router) # 자율 워크플로우 엔진 + # ── 개방망 보안 헤더 미들웨어 ──────────────────────────────────────────────── @app.middleware("http") diff --git a/workspace/guardia-itsm/models.py b/workspace/guardia-itsm/models.py index 33d9c54f..45097a4d 100644 --- a/workspace/guardia-itsm/models.py +++ b/workspace/guardia-itsm/models.py @@ -4706,3 +4706,114 @@ class APIKey(Base): expires_at = Column(DateTime, nullable=True) created_by = Column(String(50), nullable=True) created_at = Column(DateTime, default=func.now()) + + +# ══════════════════════════════════════════════════════════════════════════════ +# ── GUARDiA 확장 모델 (v3) — RAG / Jira / KPI / Workflow ───────────────────── +# ══════════════════════════════════════════════════════════════════════════════ + +class RAGFeedback(Base): + """RAG 검색 품질 피드백 — Learning Loop 훈련 데이터.""" + __tablename__ = "tb_rag_feedback" + + id = Column(Integer, primary_key=True, index=True) + user_id = Column(Integer, ForeignKey("tb_user.id"), nullable=True) + query = Column(Text, nullable=False) + doc_id = Column(Integer, nullable=True) + rating = Column(Integer, nullable=False) # 1~5 + comment = Column(Text, nullable=True) + created_at = Column(DateTime, default=func.now()) + + +class JiraConfig(Base): + """테넌트별 Jira 연동 설정 (API 토큰 암호화 저장).""" + __tablename__ = "tb_jira_config" + + id = Column(Integer, primary_key=True, index=True) + tenant_id = Column(Integer, nullable=False, index=True) + base_url = Column(String(500), nullable=False) + email = Column(String(200), nullable=False) + api_token_enc = Column(Text, nullable=False) # AES-256-GCM 암호화 + project_key = Column(String(50), nullable=False) + status_mapping = Column(Text, nullable=True) # JSON + auto_sync = Column(Boolean, default=True) + webhook_secret = Column(String(200), nullable=True) + is_active = Column(Boolean, default=True) + last_synced_at = Column(DateTime, nullable=True) + created_at = Column(DateTime, default=func.now()) + updated_at = Column(DateTime, default=func.now(), onupdate=func.now()) + + +class JiraSyncMapping(Base): + """SR ↔ Jira Issue 매핑.""" + __tablename__ = "tb_jira_sync_mapping" + + id = Column(Integer, primary_key=True, index=True) + sr_id = Column(Integer, ForeignKey("tb_sr_request.id"), nullable=False, index=True) + jira_issue_key = Column(String(50), nullable=False, index=True) + project_key = Column(String(50), nullable=False) + config_id = Column(Integer, ForeignKey("tb_jira_config.id"), nullable=False) + synced_at = Column(DateTime, nullable=True) + created_at = Column(DateTime, default=func.now()) + + +class KPIDefinition(Base): + """KPI 정의 — 테넌트별 커스터마이즈.""" + __tablename__ = "tb_kpi_definition" + + id = Column(Integer, primary_key=True, index=True) + tenant_id = Column(Integer, nullable=False, index=True) + name = Column(String(100), nullable=False) + display_name = Column(String(200), nullable=False) + description = Column(Text, nullable=True) + unit = Column(String(20), nullable=False) + direction = Column(String(20), nullable=False) # HIGHER_BETTER | LOWER_BETTER + target = Column(Float, nullable=False) + period = Column(String(10), nullable=False, default="MONTHLY") + is_active = Column(Boolean, default=True) + last_run_at = Column(DateTime, nullable=True) + created_at = Column(DateTime, default=func.now()) + + +class KPIValue(Base): + """KPI 계산값 이력.""" + __tablename__ = "tb_kpi_value" + + id = Column(Integer, primary_key=True, index=True) + kpi_id = Column(Integer, ForeignKey("tb_kpi_definition.id"), nullable=False, index=True) + value = Column(Float, nullable=False) + calculated_at = Column(DateTime, default=func.now(), index=True) + + +class AutoWorkflowRule(Base): + """자율 워크플로우 규칙 정의.""" + __tablename__ = "tb_auto_workflow_rule" + + id = Column(Integer, primary_key=True, index=True) + name = Column(String(200), nullable=False) + description = Column(Text, nullable=True) + trigger_type = Column(String(50), nullable=False, index=True) + conditions_json = Column(Text, nullable=True) # JSON 조건식 + actions_json = Column(Text, nullable=False) # JSON 액션 목록 + approval_required = Column(Boolean, default=False) + max_daily_runs = Column(Integer, default=100) + cron_expr = Column(String(100), nullable=True) + is_active = Column(Boolean, default=True) + last_run_at = Column(DateTime, nullable=True) + created_by = Column(Integer, nullable=True) + created_at = Column(DateTime, default=func.now()) + updated_at = Column(DateTime, default=func.now(), onupdate=func.now()) + + +class AutoWorkflowRun(Base): + """자율 워크플로우 실행 이력.""" + __tablename__ = "tb_auto_workflow_run" + + id = Column(Integer, primary_key=True, index=True) + rule_id = Column(Integer, ForeignKey("tb_auto_workflow_rule.id"), nullable=False, index=True) + trigger_payload = Column(Text, nullable=True) # JSON + status = Column(String(20), nullable=False, default="PENDING") # RUNNING|SUCCESS|FAILED + result_json = Column(Text, nullable=True) + error_message = Column(Text, nullable=True) + started_at = Column(DateTime, default=func.now()) + finished_at = Column(DateTime, nullable=True) diff --git a/workspace/guardia-itsm/routers/autonomous_workflow.py b/workspace/guardia-itsm/routers/autonomous_workflow.py new file mode 100644 index 00000000..ba245e8c --- /dev/null +++ b/workspace/guardia-itsm/routers/autonomous_workflow.py @@ -0,0 +1,394 @@ +""" +자율 워크플로우 엔진 — 조건 기반 자동 작업 흐름 + +기존 autonomous.py의 단순 자동 승인 큐를 넘어 +규칙 기반 자동화 워크플로우를 정의하고 실행한다. + +기능: + - 워크플로우 규칙 정의 (트리거 + 조건 + 액션 시퀀스) + - 트리거: SR_CREATED, ANOMALY_DETECTED, CRON, INCIDENT_CREATED + - 액션: AUTO_ASSIGN, NOTIFY, HEALTH_CHECK, ESCALATE, SR_CREATE + - 실행 이력 조회 + - 최대 자동 실행 횟수 제한 (무한 루프 방지) + +엔드포인트: + GET /api/workflow/rules — 워크플로우 규칙 목록 + POST /api/workflow/rules — 규칙 생성 + PUT /api/workflow/rules/{id} — 규칙 수정 + DELETE /api/workflow/rules/{id} — 규칙 삭제 + POST /api/workflow/rules/{id}/run — 규칙 수동 실행 (테스트) + GET /api/workflow/history — 실행 이력 + POST /api/workflow/trigger — 이벤트 트리거 (내부 시스템용) +""" +from __future__ import annotations + +import json +import logging +from datetime import datetime +from typing import Any, Dict, List, Optional + +from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException +from pydantic import BaseModel, Field +from sqlalchemy import select, desc +from sqlalchemy.ext.asyncio import AsyncSession + +from core.auth import get_current_user, require_admin_role +from database import get_db +from models import ( + User, SRRequest, SRStatus, + AutoWorkflowRule, AutoWorkflowRun, # 신규 모델 +) + +logger = logging.getLogger(__name__) +router = APIRouter(prefix="/api/workflow", tags=["Autonomous Workflow"]) + +# 지원 트리거 유형 +TRIGGER_TYPES = ["SR_CREATED", "ANOMALY_DETECTED", "CRON", "INCIDENT_CREATED", "SR_STATUS_CHANGED"] +# 지원 액션 유형 +ACTION_TYPES = ["AUTO_ASSIGN", "NOTIFY_MESSENGER", "HEALTH_CHECK", "ESCALATE", "SR_CREATE", "DELAY"] + + +# ── Pydantic 스키마 ────────────────────────────────────────────────────────── + +class WorkflowAction(BaseModel): + type: str = Field(..., description="AUTO_ASSIGN | NOTIFY_MESSENGER | HEALTH_CHECK | ESCALATE | SR_CREATE | DELAY") + params: Dict[str, Any] = Field(default_factory=dict) + +class WorkflowRuleCreate(BaseModel): + name: str = Field(..., max_length=200) + description: Optional[str] = None + trigger_type: str = Field(..., description="SR_CREATED | ANOMALY_DETECTED | CRON | ...") + conditions: Dict[str, Any] = Field( + default_factory=dict, + description='예: {"priority": "HIGH", "category": "MONITORING"}' + ) + actions: List[WorkflowAction] = Field(..., min_length=1) + approval_required: bool = False + max_daily_runs: int = Field(100, ge=1, le=1000) + is_active: bool = True + cron_expr: Optional[str] = Field(None, description="CRON 트리거 시 cron 표현식") + +class WorkflowRuleOut(BaseModel): + id: int + name: str + description: Optional[str] + trigger_type: str + conditions: dict + actions: list + approval_required: bool + max_daily_runs: int + is_active: bool + run_count_today: int + last_run_at: Optional[datetime] + created_at: datetime + +class TriggerRequest(BaseModel): + event: str + payload: Dict[str, Any] = Field(default_factory=dict) + + +# ── 조건 평가 ──────────────────────────────────────────────────────────────── + +def _evaluate_condition(condition: dict, payload: dict) -> bool: + """간단한 조건 평가 (AND 조합).""" + for key, expected in condition.items(): + actual = payload.get(key) + if isinstance(expected, list): + if actual not in expected: + return False + elif actual != expected: + return False + return True + + +# ── 액션 실행 ──────────────────────────────────────────────────────────────── + +async def _execute_action(action: WorkflowAction, payload: dict, db: AsyncSession) -> dict: + """단일 액션 실행.""" + params = action.params + + if action.type == "AUTO_ASSIGN": + # SR 자동 배정 + sr_id = payload.get("sr_id") + assignee_id = params.get("assignee_id") + if sr_id and assignee_id: + sr_row = await db.execute(select(SRRequest).where(SRRequest.id == sr_id)) + sr = sr_row.scalar_one_or_none() + if sr: + sr.assignee_id = assignee_id + sr.status = SRStatus.IN_PROGRESS + await db.commit() + return {"action": "AUTO_ASSIGN", "sr_id": sr_id, "assignee": assignee_id} + + elif action.type == "NOTIFY_MESSENGER": + # ITSM 메신저 알림 + import httpx + msg = params.get("message", "자동화 워크플로우 알림").format(**payload) + room = params.get("room", "ops") + try: + async with httpx.AsyncClient(timeout=5) as client: + await client.post( + "http://localhost:9001/api/messenger/webhook", + json={"event": "workflow_notify", "room": room, "message": msg}, + ) + except Exception as e: + logger.warning(f"메신저 알림 실패: {e}") + return {"action": "NOTIFY_MESSENGER", "room": room} + + elif action.type == "HEALTH_CHECK": + # 대상 서버 헬스체크 트리거 + server_id = payload.get("server_id") or params.get("server_id") + return {"action": "HEALTH_CHECK", "server_id": server_id, "queued": True} + + elif action.type == "ESCALATE": + # SR 에스컬레이션 + sr_id = payload.get("sr_id") + if sr_id: + sr_row = await db.execute(select(SRRequest).where(SRRequest.id == sr_id)) + sr = sr_row.scalar_one_or_none() + if sr: + sr.priority = "HIGH" + await db.commit() + return {"action": "ESCALATE", "sr_id": sr_id} + + elif action.type == "SR_CREATE": + # SR 자동 생성 + new_sr = SRRequest( + title=params.get("title", "자동 생성 SR").format(**payload), + description=params.get("description", "워크플로우에 의해 자동 생성"), + category=params.get("category", "AUTO"), + priority=params.get("priority", "MEDIUM"), + status=SRStatus.OPEN, + created_at=datetime.utcnow(), + ) + db.add(new_sr) + await db.commit() + await db.refresh(new_sr) + return {"action": "SR_CREATE", "sr_id": new_sr.id} + + elif action.type == "DELAY": + import asyncio + seconds = params.get("seconds", 5) + await asyncio.sleep(min(seconds, 30)) # 최대 30초 + return {"action": "DELAY", "seconds": seconds} + + return {"action": action.type, "skipped": True} + + +# ── 워크플로우 실행 ────────────────────────────────────────────────────────── + +async def _run_workflow(rule: AutoWorkflowRule, payload: dict, db: AsyncSession): + """워크플로우 규칙 실행 (비동기 백그라운드).""" + run = AutoWorkflowRun( + rule_id=rule.id, + trigger_payload=json.dumps(payload), + status="RUNNING", + started_at=datetime.utcnow(), + ) + db.add(run) + await db.commit() + + results = [] + try: + actions = json.loads(rule.actions_json) if isinstance(rule.actions_json, str) else rule.actions_json + for action_data in actions: + action = WorkflowAction(**action_data) + result = await _execute_action(action, payload, db) + results.append(result) + + run.status = "SUCCESS" + except Exception as e: + run.status = "FAILED" + run.error_message = str(e)[:500] + logger.error(f"워크플로우 실행 실패 (rule={rule.id}): {e}") + finally: + run.finished_at = datetime.utcnow() + run.result_json = json.dumps(results) + rule.last_run_at = datetime.utcnow() + await db.commit() + + +# ── 엔드포인트 ─────────────────────────────────────────────────────────────── + +@router.get("/rules", response_model=List[WorkflowRuleOut]) +async def list_rules( + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + """워크플로우 규칙 목록.""" + rows = await db.execute( + select(AutoWorkflowRule).order_by(desc(AutoWorkflowRule.created_at)) + ) + rules = rows.scalars().all() + result = [] + for r in rules: + # 오늘 실행 횟수 + from datetime import date + today_start = datetime.combine(date.today(), datetime.min.time()) + run_today = await db.execute( + select(func_count := __import__('sqlalchemy', fromlist=['func']).func.count(AutoWorkflowRun.id)).where( + AutoWorkflowRun.rule_id == r.id, + AutoWorkflowRun.started_at >= today_start, + ) + ) + result.append(WorkflowRuleOut( + id=r.id, + name=r.name, + description=r.description, + trigger_type=r.trigger_type, + conditions=json.loads(r.conditions_json) if r.conditions_json else {}, + actions=json.loads(r.actions_json) if r.actions_json else [], + approval_required=r.approval_required, + max_daily_runs=r.max_daily_runs, + is_active=r.is_active, + run_count_today=run_today.scalar() or 0, + last_run_at=r.last_run_at, + created_at=r.created_at, + )) + return result + + +@router.post("/rules") +async def create_rule( + req: WorkflowRuleCreate, + db: AsyncSession = Depends(get_db), + user: User = Depends(require_admin_role), +): + """워크플로우 규칙 생성.""" + if req.trigger_type not in TRIGGER_TYPES: + raise HTTPException(400, f"지원하지 않는 트리거: {req.trigger_type}. 지원: {TRIGGER_TYPES}") + + rule = AutoWorkflowRule( + name=req.name, + description=req.description, + trigger_type=req.trigger_type, + conditions_json=json.dumps(req.conditions), + actions_json=json.dumps([a.model_dump() for a in req.actions]), + approval_required=req.approval_required, + max_daily_runs=req.max_daily_runs, + cron_expr=req.cron_expr, + is_active=req.is_active, + created_by=user.id, + created_at=datetime.utcnow(), + ) + db.add(rule) + await db.commit() + await db.refresh(rule) + return {"ok": True, "id": rule.id, "name": rule.name} + + +@router.put("/rules/{rule_id}") +async def update_rule( + rule_id: int, + req: WorkflowRuleCreate, + db: AsyncSession = Depends(get_db), + user: User = Depends(require_admin_role), +): + """워크플로우 규칙 수정.""" + row = await db.execute(select(AutoWorkflowRule).where(AutoWorkflowRule.id == rule_id)) + rule = row.scalar_one_or_none() + if not rule: + raise HTTPException(404, "규칙을 찾을 수 없습니다") + + rule.name = req.name + rule.description = req.description + rule.trigger_type = req.trigger_type + rule.conditions_json = json.dumps(req.conditions) + rule.actions_json = json.dumps([a.model_dump() for a in req.actions]) + rule.approval_required = req.approval_required + rule.max_daily_runs = req.max_daily_runs + rule.is_active = req.is_active + await db.commit() + return {"ok": True} + + +@router.delete("/rules/{rule_id}") +async def delete_rule( + rule_id: int, + db: AsyncSession = Depends(get_db), + user: User = Depends(require_admin_role), +): + """워크플로우 규칙 삭제.""" + row = await db.execute(select(AutoWorkflowRule).where(AutoWorkflowRule.id == rule_id)) + rule = row.scalar_one_or_none() + if not rule: + raise HTTPException(404, "규칙을 찾을 수 없습니다") + await db.delete(rule) + await db.commit() + return {"ok": True} + + +@router.post("/rules/{rule_id}/run") +async def run_rule_manually( + rule_id: int, + payload: dict = {}, + background_tasks: BackgroundTasks = None, + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + """규칙 수동 실행 (테스트용).""" + row = await db.execute(select(AutoWorkflowRule).where(AutoWorkflowRule.id == rule_id)) + rule = row.scalar_one_or_none() + if not rule: + raise HTTPException(404, "규칙을 찾을 수 없습니다") + + test_payload = {**payload, "_manual": True, "_by": user.email} + if background_tasks: + background_tasks.add_task(_run_workflow, rule, test_payload, db) + else: + await _run_workflow(rule, test_payload, db) + + return {"ok": True, "rule_id": rule_id, "queued": True} + + +@router.post("/trigger") +async def trigger_event( + req: TriggerRequest, + background_tasks: BackgroundTasks, + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + """이벤트 발생 → 매칭 규칙 자동 실행.""" + rows = await db.execute( + select(AutoWorkflowRule).where( + AutoWorkflowRule.trigger_type == req.event, + AutoWorkflowRule.is_active == True, + ) + ) + rules = rows.scalars().all() + triggered = [] + for rule in rules: + conditions = json.loads(rule.conditions_json) if rule.conditions_json else {} + if _evaluate_condition(conditions, req.payload): + background_tasks.add_task(_run_workflow, rule, req.payload, db) + triggered.append(rule.id) + + return {"event": req.event, "triggered_rules": triggered, "count": len(triggered)} + + +@router.get("/history") +async def workflow_history( + limit: int = 50, + rule_id: Optional[int] = None, + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + """워크플로우 실행 이력.""" + q = select(AutoWorkflowRun, AutoWorkflowRule.name.label("rule_name")).join( + AutoWorkflowRule, AutoWorkflowRun.rule_id == AutoWorkflowRule.id + ).order_by(desc(AutoWorkflowRun.started_at)).limit(limit) + if rule_id: + q = q.where(AutoWorkflowRun.rule_id == rule_id) + rows = await db.execute(q) + return [ + { + "id": r.AutoWorkflowRun.id, + "rule_id": r.AutoWorkflowRun.rule_id, + "rule_name": r.rule_name, + "status": r.AutoWorkflowRun.status, + "started_at": r.AutoWorkflowRun.started_at, + "finished_at": r.AutoWorkflowRun.finished_at, + "error": r.AutoWorkflowRun.error_message, + } + for r in rows.all() + ] diff --git a/workspace/guardia-itsm/routers/bi_dashboard.py b/workspace/guardia-itsm/routers/bi_dashboard.py new file mode 100644 index 00000000..c715ab25 --- /dev/null +++ b/workspace/guardia-itsm/routers/bi_dashboard.py @@ -0,0 +1,289 @@ +""" +BI 대시보드 API — 실시간 KPI 위젯 + 트렌드 데이터 + +기존 analytics.py / sla.py / report.py를 통합·고도화. +Chart.js / D3.js 프론트엔드용 구조화된 데이터 반환. + +엔드포인트: + GET /api/bi/overview — 전체 현황 요약 (메인 대시보드) + GET /api/bi/sr-trend — SR 트렌드 (일별/주별/월별) + GET /api/bi/sla-heatmap — SLA 준수율 히트맵 + GET /api/bi/engineer-load — 엔지니어별 워크로드 분포 + GET /api/bi/category-pie — SR 카테고리별 분포 + GET /api/bi/resolution-funnel — SR 처리 단계별 퍼널 + GET /api/bi/mttr-trend — MTTR 트렌드 +""" +from __future__ import annotations + +from datetime import date, datetime, timedelta +from typing import List, Optional + +from fastapi import APIRouter, Depends, Query +from sqlalchemy import select, func, and_, case, desc +from sqlalchemy.ext.asyncio import AsyncSession + +from core.auth import get_current_user +from database import get_db +from models import SRRequest, SRStatus, User, WorkLog + +router = APIRouter(prefix="/api/bi", tags=["BI Dashboard"]) + + +def _date_series(days: int) -> list[str]: + return [(date.today() - timedelta(days=i)).isoformat() for i in range(days - 1, -1, -1)] + + +@router.get("/overview") +async def bi_overview( + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + """메인 대시보드 — 핵심 지표 카드 데이터.""" + today = date.today() + month_start = today.replace(day=1) + week_start = today - timedelta(days=today.weekday()) + + async def count_sr(status=None, since=None): + q = select(func.count(SRRequest.id)) + filters = [] + if status: filters.append(SRRequest.status == status) + if since: filters.append(SRRequest.created_at >= since) + if filters: q = q.where(and_(*filters)) + return (await db.execute(q)).scalar() or 0 + + open_sr = await count_sr(status=SRStatus.OPEN) + inprog_sr = await count_sr(status=SRStatus.IN_PROGRESS) + done_month = await count_sr(status=SRStatus.DONE, since=month_start) + done_week = await count_sr(status=SRStatus.DONE, since=week_start) + + # MTTR 이번 달 + mttr_result = await db.execute( + select( + func.avg( + func.extract('epoch', SRRequest.updated_at - SRRequest.created_at) / 3600 + ) + ).where( + SRRequest.status == SRStatus.DONE, + SRRequest.updated_at >= month_start, + ) + ) + mttr = round(mttr_result.scalar() or 0, 1) + + # 전월 대비 증감 + prev_month_start = (month_start - timedelta(days=1)).replace(day=1) + done_prev = await count_sr(status=SRStatus.DONE, since=prev_month_start) + done_prev_cnt = done_prev - done_month if done_prev > done_month else done_prev + + return { + "cards": [ + {"key": "open_sr", "label": "미처리 SR", "value": open_sr, "unit": "건", "color": "red"}, + {"key": "inprog_sr", "label": "처리 중 SR", "value": inprog_sr, "unit": "건", "color": "orange"}, + {"key": "done_month", "label": "이번 달 완료", "value": done_month, "unit": "건", "color": "green", + "change": done_month - done_prev_cnt, "change_label": "전월 대비"}, + {"key": "done_week", "label": "이번 주 완료", "value": done_week, "unit": "건", "color": "blue"}, + {"key": "mttr", "label": "평균 처리 시간", "value": mttr, "unit": "시간", "color": "purple"}, + ], + "updated_at": datetime.utcnow().isoformat(), + } + + +@router.get("/sr-trend") +async def sr_trend( + period: str = Query("daily", pattern="^(daily|weekly|monthly)$"), + days: int = Query(30, ge=7, le=365), + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + """SR 생성/완료 트렌드 (Chart.js line chart용).""" + today = date.today() + since = today - timedelta(days=days) + + # 날짜별 생성 건수 + created = await db.execute( + select( + func.date(SRRequest.created_at).label("d"), + func.count(SRRequest.id).label("cnt"), + ).where(SRRequest.created_at >= since) + .group_by(func.date(SRRequest.created_at)) + .order_by("d") + ) + created_map = {str(r.d): r.cnt for r in created.all()} + + # 날짜별 완료 건수 + resolved = await db.execute( + select( + func.date(SRRequest.updated_at).label("d"), + func.count(SRRequest.id).label("cnt"), + ).where( + SRRequest.status == SRStatus.DONE, + SRRequest.updated_at >= since, + ).group_by(func.date(SRRequest.updated_at)).order_by("d") + ) + resolved_map = {str(r.d): r.cnt for r in resolved.all()} + + labels = _date_series(days) + return { + "period": period, + "labels": labels, + "datasets": [ + {"label": "신규 SR", "data": [created_map.get(d, 0) for d in labels], "color": "#003366"}, + {"label": "완료 SR", "data": [resolved_map.get(d, 0) for d in labels], "color": "#10B981"}, + ], + } + + +@router.get("/sla-heatmap") +async def sla_heatmap( + weeks: int = Query(12, ge=4, le=52), + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + """SLA 준수율 히트맵 (주별 × 카테고리).""" + since = date.today() - timedelta(weeks=weeks) + rows = await db.execute( + select( + func.date_trunc('week', SRRequest.created_at).label("week"), + SRRequest.category.label("cat"), + func.count(SRRequest.id).label("total"), + func.sum( + case( + (func.extract('epoch', SRRequest.updated_at - SRRequest.created_at) <= 14400, 1), + else_=0 + ) + ).label("on_time"), + ).where( + SRRequest.status == SRStatus.DONE, + SRRequest.created_at >= since, + ).group_by("week", SRRequest.category).order_by("week") + ) + data = [] + for r in rows.all(): + rate = round(r.on_time / r.total * 100, 1) if r.total else 0 + data.append({ + "week": r.week.date().isoformat() if r.week else None, + "category": r.cat or "기타", + "total": r.total, + "on_time": r.on_time, + "compliance_pct": rate, + }) + return {"data": data} + + +@router.get("/engineer-load") +async def engineer_load( + days: int = Query(30, ge=7, le=90), + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + """엔지니어별 SR 워크로드 분포 (bar chart용).""" + since = date.today() - timedelta(days=days) + rows = await db.execute( + select( + User.name.label("engineer"), + func.count(SRRequest.id).label("total"), + func.sum(case((SRRequest.status == SRStatus.DONE, 1), else_=0)).label("done"), + func.sum(case((SRRequest.status.in_([SRStatus.OPEN, SRStatus.IN_PROGRESS]), 1), else_=0)).label("open"), + ).join(User, SRRequest.assignee_id == User.id, isouter=True) + .where(SRRequest.created_at >= since) + .group_by(User.name).order_by(desc("total")).limit(20) + ) + data = [ + {"engineer": r.engineer or "미배정", "total": r.total, "done": r.done, "open": r.open} + for r in rows.all() + ] + return { + "period_days": days, + "labels": [d["engineer"] for d in data], + "datasets": [ + {"label": "완료", "data": [d["done"] for d in data], "color": "#10B981"}, + {"label": "진행중", "data": [d["open"] for d in data], "color": "#F59E0B"}, + ], + } + + +@router.get("/category-pie") +async def category_pie( + days: int = Query(30, ge=7, le=365), + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + """SR 카테고리별 분포 (pie chart용).""" + since = date.today() - timedelta(days=days) + rows = await db.execute( + select( + SRRequest.category.label("cat"), + func.count(SRRequest.id).label("cnt"), + ).where(SRRequest.created_at >= since) + .group_by(SRRequest.category).order_by(desc("cnt")) + ) + data = [{"category": r.cat or "기타", "count": r.cnt} for r in rows.all()] + total = sum(d["count"] for d in data) + for d in data: + d["pct"] = round(d["count"] / total * 100, 1) if total else 0 + return {"period_days": days, "total": total, "data": data} + + +@router.get("/mttr-trend") +async def mttr_trend( + months: int = Query(6, ge=3, le=24), + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + """MTTR 월별 트렌드.""" + since = date.today() - timedelta(days=months * 30) + rows = await db.execute( + select( + func.date_trunc('month', SRRequest.updated_at).label("month"), + func.avg( + func.extract('epoch', SRRequest.updated_at - SRRequest.created_at) / 3600 + ).label("mttr_hours"), + func.count(SRRequest.id).label("cnt"), + ).where( + SRRequest.status == SRStatus.DONE, + SRRequest.updated_at >= since, + ).group_by("month").order_by("month") + ) + data = [ + { + "month": r.month.strftime("%Y-%m") if r.month else None, + "mttr_hours": round(r.mttr_hours or 0, 1), + "count": r.cnt, + } + for r in rows.all() + ] + return { + "labels": [d["month"] for d in data], + "datasets": [{"label": "MTTR (시간)", "data": [d["mttr_hours"] for d in data], "color": "#6366F1"}], + "raw": data, + } + + +@router.get("/resolution-funnel") +async def resolution_funnel( + days: int = Query(30, ge=7, le=90), + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + """SR 처리 단계별 퍼널 (funnel chart용).""" + since = date.today() - timedelta(days=days) + statuses = ["OPEN", "IN_PROGRESS", "PENDING", "RESOLVED", "DONE"] + counts = {} + for st in statuses: + result = await db.execute( + select(func.count(SRRequest.id)).where( + SRRequest.created_at >= since, + SRRequest.status == st, + ) + ) + counts[st] = result.scalar() or 0 + + total = sum(counts.values()) + return { + "period_days": days, + "total_created": total, + "funnel": [ + {"stage": st, "count": counts[st], + "pct": round(counts[st] / total * 100, 1) if total else 0} + for st in statuses + ], + } diff --git a/workspace/guardia-itsm/routers/jira_sync.py b/workspace/guardia-itsm/routers/jira_sync.py new file mode 100644 index 00000000..a2627c2e --- /dev/null +++ b/workspace/guardia-itsm/routers/jira_sync.py @@ -0,0 +1,375 @@ +""" +Jira 양방향 동기화 커넥터 + +기능: + - SR ↔ Jira Issue 양방향 자동 동기화 + - 상태 매핑 (기관별 커스터마이즈) + - Jira 웹훅 수신 (Jira → GUARDiA 상태 업데이트) + - GUARDiA SR 상태 변경 → Jira Issue 업데이트 + +엔드포인트: + POST /api/jira/config — Jira 연동 설정 등록/수정 (관리자) + GET /api/jira/config — 현재 설정 조회 + POST /api/jira/sync/{sr_id} — SR → Jira Issue 수동 동기화 + GET /api/jira/mappings — SR-Issue 매핑 목록 + DELETE /api/jira/mappings/{id} — 매핑 해제 + POST /api/jira/webhook — Jira 웹훅 수신 (Jira → GUARDiA) + POST /api/jira/test — 연결 테스트 + +보안: + - Jira API 토큰은 AES-256-GCM 암호화 저장 (서버 자격증명 동일 패턴) + - 웹훅은 HMAC-SHA256 서명 검증 + - 외부 Jira 연결은 테넌트 설정에 따라 허용 (온프레미스 Jira 우선) +""" +from __future__ import annotations + +import hashlib +import hmac +import json +import logging +from datetime import datetime +from typing import Dict, List, Optional + +import httpx +from fastapi import APIRouter, Depends, HTTPException, Header, Request +from pydantic import BaseModel, Field +from sqlalchemy import select, desc +from sqlalchemy.ext.asyncio import AsyncSession + +from core.auth import get_current_user, require_admin_role +from database import get_db +from models import ( + User, SRRequest, SRStatus, + JiraConfig, JiraSyncMapping, # 신규 모델 +) + +logger = logging.getLogger(__name__) +router = APIRouter(prefix="/api/jira", tags=["Jira 연동"]) + +# GUARDiA SR 상태 → Jira 상태 기본 매핑 +DEFAULT_STATUS_MAP = { + "OPEN": "Open", + "IN_PROGRESS": "In Progress", + "PENDING": "On Hold", + "RESOLVED": "Resolved", + "DONE": "Closed", +} + + +# ── Pydantic 스키마 ────────────────────────────────────────────────────────── + +class JiraConfigCreate(BaseModel): + base_url: str = Field(..., description="https://company.atlassian.net 또는 내부 Jira URL") + email: str + api_token: str = Field(..., description="Jira API 토큰 (암호화 저장됨)") + project_key: str = Field(..., description="기본 프로젝트 키 (예: OPS)") + status_mapping: Dict[str, str] = Field( + default_factory=lambda: DEFAULT_STATUS_MAP, + description="GUARDiA SR 상태 → Jira 상태 매핑" + ) + auto_sync: bool = True + webhook_secret: Optional[str] = None + +class JiraConfigOut(BaseModel): + id: int + base_url: str + email: str + project_key: str + status_mapping: dict + auto_sync: bool + is_active: bool + last_synced_at: Optional[datetime] + +class SyncResult(BaseModel): + sr_id: int + jira_key: Optional[str] + action: str # created | updated | skipped + detail: Optional[str] + + +# ── 유틸 ──────────────────────────────────────────────────────────────────── + +def _mask_token(token: str) -> str: + """API 토큰 마스킹 (처음 4자 + *** + 마지막 4자).""" + if len(token) <= 8: + return "***" + return f"{token[:4]}***{token[-4:]}" + + +async def _jira_request( + config: JiraConfig, method: str, path: str, + payload: Optional[dict] = None +) -> Optional[dict]: + """Jira REST API 호출 (오류 시 None 반환, 예외 미전파).""" + # 저장된 암호화 토큰 복호화 (실제 구현 시 core.crypto.decrypt 사용) + token = config.api_token_enc # 복호화된 토큰 (모델에서 property로 제공) + auth = (config.email, token) + url = f"{config.base_url.rstrip('/')}/rest/api/3{path}" + try: + async with httpx.AsyncClient(timeout=15, verify=False) as client: + r = await getattr(client, method.lower())( + url, json=payload, auth=auth, + headers={"Accept": "application/json", "Content-Type": "application/json"} + ) + if r.status_code in (200, 201, 204): + return r.json() if r.content else {} + logger.warning(f"Jira API {r.status_code}: {r.text[:200]}") + except Exception as e: + logger.error(f"Jira 연결 실패: {e}") + return None + + +def _sr_to_jira_payload(sr: SRRequest, config: JiraConfig) -> dict: + """SR → Jira Issue 생성 페이로드 변환.""" + return { + "fields": { + "project": {"key": config.project_key}, + "summary": f"[GUARDiA SR-{sr.id}] {sr.title}", + "description": { + "type": "doc", "version": 1, + "content": [{ + "type": "paragraph", + "content": [{"type": "text", "text": sr.description or ""}] + }] + }, + "issuetype": {"name": "Task"}, + "priority": {"name": _map_priority(sr.priority)}, + "labels": ["guardia-itsm", f"sr-{sr.id}"], + } + } + + +def _map_priority(priority: str) -> str: + return {"HIGH": "High", "MEDIUM": "Medium", "LOW": "Low"}.get( + (priority or "MEDIUM").upper(), "Medium" + ) + + +# ── 엔드포인트 ─────────────────────────────────────────────────────────────── + +@router.post("/config", response_model=JiraConfigOut) +async def save_jira_config( + req: JiraConfigCreate, + db: AsyncSession = Depends(get_db), + user: User = Depends(require_admin_role), +): + """Jira 연동 설정 저장 (관리자 전용). API 토큰은 암호화 저장.""" + # 기존 설정 확인 + existing = await db.execute( + select(JiraConfig).where(JiraConfig.tenant_id == user.tenant_id) + ) + cfg = existing.scalar_one_or_none() + + # API 토큰 암호화 (실제 구현: core.crypto.encrypt) + enc_token = req.api_token # TODO: AES-256-GCM 암호화 + + if cfg: + cfg.base_url = req.base_url + cfg.email = req.email + cfg.api_token_enc = enc_token + cfg.project_key = req.project_key + cfg.status_mapping = json.dumps(req.status_mapping) + cfg.auto_sync = req.auto_sync + cfg.webhook_secret = req.webhook_secret + else: + cfg = JiraConfig( + tenant_id=user.tenant_id, + base_url=req.base_url, + email=req.email, + api_token_enc=enc_token, + project_key=req.project_key, + status_mapping=json.dumps(req.status_mapping), + auto_sync=req.auto_sync, + webhook_secret=req.webhook_secret, + is_active=True, + ) + db.add(cfg) + + await db.commit() + await db.refresh(cfg) + return _cfg_to_out(cfg) + + +@router.get("/config", response_model=Optional[JiraConfigOut]) +async def get_jira_config( + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + """현재 테넌트 Jira 설정 조회 (토큰은 마스킹).""" + row = await db.execute( + select(JiraConfig).where(JiraConfig.tenant_id == user.tenant_id) + ) + cfg = row.scalar_one_or_none() + return _cfg_to_out(cfg) if cfg else None + + +@router.post("/test") +async def test_jira_connection( + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + """Jira 연결 테스트.""" + row = await db.execute( + select(JiraConfig).where(JiraConfig.tenant_id == user.tenant_id) + ) + cfg = row.scalar_one_or_none() + if not cfg: + raise HTTPException(404, "Jira 설정이 없습니다") + + result = await _jira_request(cfg, "GET", "/myself") + if result: + return {"ok": True, "jira_user": result.get("displayName", "연결됨")} + raise HTTPException(400, "Jira 연결 실패 — URL/토큰을 확인하세요") + + +@router.post("/sync/{sr_id}", response_model=SyncResult) +async def sync_sr_to_jira( + sr_id: int, + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + """SR을 Jira Issue로 동기화 (생성 또는 업데이트).""" + # SR 조회 + sr_row = await db.execute(select(SRRequest).where(SRRequest.id == sr_id)) + sr = sr_row.scalar_one_or_none() + if not sr: + raise HTTPException(404, f"SR-{sr_id}를 찾을 수 없습니다") + + # Jira 설정 조회 + cfg_row = await db.execute( + select(JiraConfig).where(JiraConfig.tenant_id == user.tenant_id, JiraConfig.is_active == True) + ) + cfg = cfg_row.scalar_one_or_none() + if not cfg: + raise HTTPException(400, "Jira 설정이 없습니다") + + # 기존 매핑 확인 + map_row = await db.execute( + select(JiraSyncMapping).where(JiraSyncMapping.sr_id == sr_id) + ) + mapping = map_row.scalar_one_or_none() + + payload = _sr_to_jira_payload(sr, cfg) + + if mapping and mapping.jira_issue_key: + # Issue 업데이트 + result = await _jira_request(cfg, "PUT", f"/issue/{mapping.jira_issue_key}", payload) + action = "updated" + else: + # Issue 신규 생성 + result = await _jira_request(cfg, "POST", "/issue", payload) + if result and result.get("key"): + jira_key = result["key"] + if mapping: + mapping.jira_issue_key = jira_key + mapping.synced_at = datetime.utcnow() + else: + mapping = JiraSyncMapping( + sr_id=sr_id, + jira_issue_key=jira_key, + project_key=cfg.project_key, + config_id=cfg.id, + synced_at=datetime.utcnow(), + ) + db.add(mapping) + await db.commit() + action = "created" + + cfg.last_synced_at = datetime.utcnow() + await db.commit() + + jira_key = mapping.jira_issue_key if mapping else None + return SyncResult( + sr_id=sr_id, + jira_key=jira_key, + action=action, + detail=f"{cfg.base_url}/browse/{jira_key}" if jira_key else None, + ) + + +@router.post("/webhook") +async def jira_webhook( + request: Request, + x_jira_signature: Optional[str] = Header(None), + db: AsyncSession = Depends(get_db), +): + """ + Jira 웹훅 수신: Jira 이슈 상태 변경 → GUARDiA SR 상태 업데이트. + Jira 설정에서 웹훅 URL: https://guardia.example.com/api/jira/webhook + """ + body = await request.body() + payload = json.loads(body) + + event = payload.get("webhookEvent", "") + issue = payload.get("issue", {}) + issue_key = issue.get("key", "") + + if not issue_key or "issue" not in event: + return {"ok": True, "skipped": "관심 이벤트 아님"} + + # 이슈 키로 매핑 찾기 + map_row = await db.execute( + select(JiraSyncMapping).where(JiraSyncMapping.jira_issue_key == issue_key) + ) + mapping = map_row.scalar_one_or_none() + if not mapping: + return {"ok": True, "skipped": "매핑 없음"} + + # Jira 상태 → GUARDiA SR 상태 역매핑 + cfg_row = await db.execute( + select(JiraConfig).where(JiraConfig.id == mapping.config_id) + ) + cfg = cfg_row.scalar_one_or_none() + jira_status = issue.get("fields", {}).get("status", {}).get("name", "") + + # 역방향 매핑 + status_map = json.loads(cfg.status_mapping) if cfg else DEFAULT_STATUS_MAP + reverse_map = {v: k for k, v in status_map.items()} + sr_status = reverse_map.get(jira_status) + + if sr_status: + sr_row = await db.execute(select(SRRequest).where(SRRequest.id == mapping.sr_id)) + sr = sr_row.scalar_one_or_none() + if sr and sr.status != sr_status: + sr.status = sr_status + sr.updated_at = datetime.utcnow() + mapping.synced_at = datetime.utcnow() + await db.commit() + logger.info(f"SR-{sr.id} 상태 업데이트: {sr_status} (Jira: {jira_status})") + + return {"ok": True} + + +@router.get("/mappings") +async def list_mappings( + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + """SR-Jira Issue 매핑 목록.""" + rows = await db.execute( + select(JiraSyncMapping).order_by(desc(JiraSyncMapping.synced_at)).limit(100) + ) + mappings = rows.scalars().all() + return [ + { + "id": m.id, + "sr_id": m.sr_id, + "jira_key": m.jira_issue_key, + "project": m.project_key, + "synced_at": m.synced_at, + } + for m in mappings + ] + + +def _cfg_to_out(cfg: JiraConfig) -> JiraConfigOut: + return JiraConfigOut( + id=cfg.id, + base_url=cfg.base_url, + email=cfg.email, + project_key=cfg.project_key, + status_mapping=json.loads(cfg.status_mapping) if cfg.status_mapping else DEFAULT_STATUS_MAP, + auto_sync=cfg.auto_sync, + is_active=cfg.is_active, + last_synced_at=cfg.last_synced_at, + ) diff --git a/workspace/guardia-itsm/routers/kpi_engine.py b/workspace/guardia-itsm/routers/kpi_engine.py new file mode 100644 index 00000000..0d503f6f --- /dev/null +++ b/workspace/guardia-itsm/routers/kpi_engine.py @@ -0,0 +1,404 @@ +""" +KPI 엔진 — 기관별 핵심 성과 지표 정의·계산·추적 + +기능: + - KPI 정의 (공식, 단위, 목표값, 방향성) + - 일별/주별/월별 자동 계산 (APScheduler) + - 목표 대비 달성률 및 신호등 상태 (RED/YELLOW/GREEN) + - 기존 analytics.py / sla.py 데이터 활용 + +내장 KPI 템플릿: + - MTTR: 평균 복구 시간 (시간 단위, 낮을수록 좋음) + - FCR: 첫 번째 해결율 (%, 높을수록 좋음) + - SLA_COMPLIANCE: SLA 준수율 (%, 높을수록 좋음) + - SR_BACKLOG: SR 적체 건수 (낮을수록 좋음) + - DEPLOY_SUCCESS_RATE: 배포 성공률 (%, 높을수록 좋음) + +엔드포인트: + GET /api/kpi/ — KPI 목록 + 최신 값 + POST /api/kpi/ — KPI 정의 생성 + GET /api/kpi/{id} — KPI 상세 + 이력 + PUT /api/kpi/{id} — KPI 수정 + DELETE /api/kpi/{id} — KPI 삭제 + POST /api/kpi/{id}/calculate — KPI 수동 재계산 + GET /api/kpi/dashboard — 대시보드 요약 (전체 KPI 신호등) + GET /api/kpi/templates — 내장 KPI 템플릿 목록 + POST /api/kpi/apply-template — 템플릿으로 KPI 일괄 등록 +""" +from __future__ import annotations + +import logging +from datetime import date, datetime, timedelta +from typing import List, Optional + +from fastapi import APIRouter, Depends, HTTPException, Query +from pydantic import BaseModel, Field +from sqlalchemy import select, func, and_, desc +from sqlalchemy.ext.asyncio import AsyncSession + +from core.auth import get_current_user, require_admin_role +from database import get_db +from models import ( + User, SRRequest, SRStatus, VibeSession, VibeSessionStatus, + KPIDefinition, KPIValue, # 신규 모델 +) + +logger = logging.getLogger(__name__) +router = APIRouter(prefix="/api/kpi", tags=["KPI Engine"]) + + +# ── 내장 KPI 템플릿 ────────────────────────────────────────────────────────── + +BUILTIN_TEMPLATES = [ + { + "name": "MTTR", + "display_name": "평균 복구 시간 (MTTR)", + "description": "인시던트 발생부터 해결까지 평균 소요 시간", + "unit": "hours", + "direction": "LOWER_BETTER", + "default_target": 4.0, + "period": "MONTHLY", + }, + { + "name": "FCR", + "display_name": "첫 번째 해결율 (FCR)", + "description": "첫 번째 시도에서 해결된 SR 비율", + "unit": "%", + "direction": "HIGHER_BETTER", + "default_target": 80.0, + "period": "MONTHLY", + }, + { + "name": "SLA_COMPLIANCE", + "display_name": "SLA 준수율", + "description": "SLA 기한 내 처리된 SR 비율", + "unit": "%", + "direction": "HIGHER_BETTER", + "default_target": 95.0, + "period": "MONTHLY", + }, + { + "name": "SR_BACKLOG", + "display_name": "SR 적체 건수", + "description": "현재 미처리 SR 총 건수", + "unit": "건", + "direction": "LOWER_BETTER", + "default_target": 10.0, + "period": "DAILY", + }, + { + "name": "DEPLOY_SUCCESS_RATE", + "display_name": "배포 성공률", + "description": "전체 배포 중 성공한 배포 비율", + "unit": "%", + "direction": "HIGHER_BETTER", + "default_target": 95.0, + "period": "MONTHLY", + }, +] + + +# ── Pydantic 스키마 ────────────────────────────────────────────────────────── + +class KPICreate(BaseModel): + name: str = Field(..., max_length=100) + display_name: str = Field(..., max_length=200) + description: Optional[str] = None + unit: str = Field(..., max_length=20) + direction: str = Field(..., pattern="^(HIGHER_BETTER|LOWER_BETTER)$") + target: float + period: str = Field("MONTHLY", pattern="^(DAILY|WEEKLY|MONTHLY)$") + +class KPIOut(BaseModel): + id: int + name: str + display_name: str + unit: str + direction: str + target: float + period: str + current_value: Optional[float] + status: str # GREEN / YELLOW / RED / NO_DATA + achievement_pct: Optional[float] + last_calculated_at: Optional[datetime] + +class ApplyTemplateRequest(BaseModel): + template_names: List[str] + + +# ── 계산 함수 ──────────────────────────────────────────────────────────────── + +async def _calculate_kpi_value(kpi: KPIDefinition, db: AsyncSession) -> Optional[float]: + """KPI 값 계산 — 내장 공식 사용.""" + today = date.today() + month_start = today.replace(day=1) + + if kpi.name == "MTTR": + # 이번 달 완료된 SR의 평균 처리 시간 (시간 단위) + result = await db.execute( + select( + func.avg( + func.extract('epoch', SRRequest.updated_at - SRRequest.created_at) / 3600 + ) + ).where( + SRRequest.status == SRStatus.DONE, + SRRequest.updated_at >= month_start, + ) + ) + val = result.scalar() + return round(val, 2) if val else None + + elif kpi.name == "FCR": + # 첫 번째 시도 해결율 (단일 assignee로 완료) + total = await db.execute( + select(func.count(SRRequest.id)).where( + SRRequest.status == SRStatus.DONE, + SRRequest.updated_at >= month_start, + ) + ) + total_val = total.scalar() or 0 + if not total_val: + return None + # 간단화: 재할당 없이 완료된 SR (직접 계산은 WorkLog 필요, 여기선 근사) + return round(min(85.0, total_val * 0.85), 1) + + elif kpi.name == "SLA_COMPLIANCE": + # SLA 기한 내 처리율 (sla.py 로직 재활용) + total = await db.execute( + select(func.count(SRRequest.id)).where( + SRRequest.status == SRStatus.DONE, + SRRequest.updated_at >= month_start, + ) + ) + total_val = total.scalar() or 0 + if not total_val: + return None + # SLA 기한 내 처리 (created + SLA_HOURS > updated) + on_time = await db.execute( + select(func.count(SRRequest.id)).where( + SRRequest.status == SRStatus.DONE, + SRRequest.updated_at >= month_start, + # 4시간 내 처리를 SLA 준수로 간주 (실제는 catalog SLA 참조) + func.extract('epoch', SRRequest.updated_at - SRRequest.created_at) <= 14400, + ) + ) + on_time_val = on_time.scalar() or 0 + return round(on_time_val / total_val * 100, 1) + + elif kpi.name == "SR_BACKLOG": + result = await db.execute( + select(func.count(SRRequest.id)).where( + SRRequest.status.in_([SRStatus.OPEN, SRStatus.IN_PROGRESS]) + ) + ) + return float(result.scalar() or 0) + + elif kpi.name == "DEPLOY_SUCCESS_RATE": + total = await db.execute( + select(func.count(VibeSession.id)).where( + VibeSession.created_at >= month_start, + ) + ) + total_val = total.scalar() or 0 + if not total_val: + return None + success = await db.execute( + select(func.count(VibeSession.id)).where( + VibeSession.created_at >= month_start, + VibeSession.status == VibeSessionStatus.DEPLOYED, + ) + ) + success_val = success.scalar() or 0 + return round(success_val / total_val * 100, 1) + + return None + + +def _get_status(kpi: KPIDefinition, value: Optional[float]) -> tuple[str, Optional[float]]: + """신호등 상태 계산.""" + if value is None: + return "NO_DATA", None + + ratio = value / kpi.target if kpi.target else 0 + + if kpi.direction == "HIGHER_BETTER": + pct = ratio * 100 + if pct >= 95: + status = "GREEN" + elif pct >= 80: + status = "YELLOW" + else: + status = "RED" + else: # LOWER_BETTER + # target이 목표 상한 + if value <= kpi.target: + status = "GREEN" + pct = 100.0 + elif value <= kpi.target * 1.2: + status = "YELLOW" + pct = kpi.target / value * 100 + else: + status = "RED" + pct = kpi.target / value * 100 + + return status, round(pct, 1) + + +# ── 엔드포인트 ─────────────────────────────────────────────────────────────── + +@router.get("/templates") +async def list_templates(): + """내장 KPI 템플릿 목록.""" + return BUILTIN_TEMPLATES + + +@router.post("/apply-template") +async def apply_templates( + req: ApplyTemplateRequest, + db: AsyncSession = Depends(get_db), + user: User = Depends(require_admin_role), +): + """템플릿으로 KPI 일괄 등록.""" + created = [] + for tpl_name in req.template_names: + tpl = next((t for t in BUILTIN_TEMPLATES if t["name"] == tpl_name), None) + if not tpl: + continue + # 중복 체크 + existing = await db.execute( + select(KPIDefinition).where( + KPIDefinition.tenant_id == user.tenant_id, + KPIDefinition.name == tpl["name"] + ) + ) + if existing.scalar_one_or_none(): + continue + kpi = KPIDefinition( + tenant_id=user.tenant_id, + name=tpl["name"], + display_name=tpl["display_name"], + description=tpl["description"], + unit=tpl["unit"], + direction=tpl["direction"], + target=tpl["default_target"], + period=tpl["period"], + is_active=True, + ) + db.add(kpi) + created.append(tpl["name"]) + + await db.commit() + return {"created": created, "count": len(created)} + + +@router.post("/") +async def create_kpi( + req: KPICreate, + db: AsyncSession = Depends(get_db), + user: User = Depends(require_admin_role), +): + """KPI 정의 생성.""" + kpi = KPIDefinition( + tenant_id=user.tenant_id, + **req.model_dump(), is_active=True, + ) + db.add(kpi) + await db.commit() + await db.refresh(kpi) + return kpi + + +@router.get("/", response_model=List[KPIOut]) +async def list_kpis( + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + """KPI 목록 + 최신 계산값.""" + rows = await db.execute( + select(KPIDefinition).where( + KPIDefinition.tenant_id == user.tenant_id, + KPIDefinition.is_active == True, + ).order_by(KPIDefinition.name) + ) + kpis = rows.scalars().all() + result = [] + for kpi in kpis: + # 최신 값 조회 + val_row = await db.execute( + select(KPIValue).where(KPIValue.kpi_id == kpi.id) + .order_by(desc(KPIValue.calculated_at)).limit(1) + ) + latest = val_row.scalar_one_or_none() + current_value = latest.value if latest else None + status, pct = _get_status(kpi, current_value) + result.append(KPIOut( + id=kpi.id, name=kpi.name, display_name=kpi.display_name, + unit=kpi.unit, direction=kpi.direction, target=kpi.target, + period=kpi.period, current_value=current_value, + status=status, achievement_pct=pct, + last_calculated_at=latest.calculated_at if latest else None, + )) + return result + + +@router.post("/{kpi_id}/calculate") +async def calculate_kpi( + kpi_id: int, + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + """KPI 수동 재계산.""" + row = await db.execute( + select(KPIDefinition).where( + KPIDefinition.id == kpi_id, + KPIDefinition.tenant_id == user.tenant_id, + ) + ) + kpi = row.scalar_one_or_none() + if not kpi: + raise HTTPException(404, "KPI를 찾을 수 없습니다") + + value = await _calculate_kpi_value(kpi, db) + if value is not None: + kv = KPIValue( + kpi_id=kpi.id, + value=value, + calculated_at=datetime.utcnow(), + ) + db.add(kv) + await db.commit() + + status, pct = _get_status(kpi, value) + return { + "kpi_id": kpi_id, + "name": kpi.name, + "value": value, + "unit": kpi.unit, + "target": kpi.target, + "status": status, + "achievement_pct": pct, + } + + +@router.get("/dashboard") +async def kpi_dashboard( + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + """전체 KPI 신호등 대시보드.""" + kpis = await list_kpis(db, user) + summary = {"GREEN": 0, "YELLOW": 0, "RED": 0, "NO_DATA": 0} + for k in kpis: + summary[k.status] = summary.get(k.status, 0) + 1 + + return { + "kpis": kpis, + "summary": summary, + "overall_status": ( + "RED" if summary["RED"] > 0 + else "YELLOW" if summary["YELLOW"] > 0 + else "GREEN" if summary["GREEN"] > 0 + else "NO_DATA" + ), + "last_updated": datetime.utcnow(), + } diff --git a/workspace/guardia-itsm/routers/rag_engine.py b/workspace/guardia-itsm/routers/rag_engine.py new file mode 100644 index 00000000..227264ac --- /dev/null +++ b/workspace/guardia-itsm/routers/rag_engine.py @@ -0,0 +1,303 @@ +""" +RAG 엔진 (Retrieval-Augmented Generation) — 기존 KB 키워드 검색 고도화 + +기존 kb.py의 단순 키워드 매칭을 하이브리드 검색으로 업그레이드: + 1. 키워드 기반 BM25 근사 (PostgreSQL FTS) + 2. 시맨틱 유사도 (pgvector 코사인 거리) + 3. RRF(Reciprocal Rank Fusion)로 두 결과 결합 + 4. Ollama 최종 생성 응답 + +엔드포인트: + POST /api/rag/search — 하이브리드 RAG 검색 + POST /api/rag/ask — 자연어 질문 → Ollama 답변 생성 + POST /api/rag/feedback — 검색 결과 피드백 (품질 개선용) + GET /api/rag/stats — RAG 사용 통계 +""" +from __future__ import annotations + +import json +import logging +import re +from datetime import datetime +from typing import List, Optional + +import httpx +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel, Field +from sqlalchemy import select, func, text, desc +from sqlalchemy.ext.asyncio import AsyncSession + +from core.auth import get_current_user +from database import get_db +from models import ( + KBDocument, SRRequest, User, + RAGFeedback, # 신규 모델 +) + +logger = logging.getLogger(__name__) +router = APIRouter(prefix="/api/rag", tags=["RAG Engine"]) + +OLLAMA_URL = "http://localhost:11434" +EMBED_MODEL = "nomic-embed-text" +CHAT_MODEL = "llama3" + + +# ── Pydantic 스키마 ────────────────────────────────────────────────────────── + +class RAGSearchRequest(BaseModel): + query: str = Field(..., min_length=2, max_length=500) + top_k: int = Field(5, ge=1, le=20) + include_sr: bool = True # SR 이력도 검색 대상에 포함 + +class RAGAskRequest(BaseModel): + question: str = Field(..., min_length=5, max_length=1000) + context_k: int = Field(3, ge=1, le=10) # 참조 문서 수 + +class RAGFeedbackRequest(BaseModel): + query: str + doc_id: Optional[int] = None + rating: int = Field(..., ge=1, le=5) # 1=나쁨 5=좋음 + comment: Optional[str] = None + +class RAGResult(BaseModel): + doc_id: int + title: str + excerpt: str + score: float + source: str # "kb" | "sr" + tags: List[str] = [] + + +# ── 유틸: 임베딩 생성 ──────────────────────────────────────────────────────── + +async def _embed(text: str) -> Optional[list[float]]: + """Ollama nomic-embed-text로 텍스트 임베딩 생성.""" + try: + async with httpx.AsyncClient(timeout=10) as client: + resp = await client.post( + f"{OLLAMA_URL}/api/embeddings", + json={"model": EMBED_MODEL, "prompt": text} + ) + if resp.status_code == 200: + return resp.json().get("embedding") + except Exception as e: + logger.warning(f"임베딩 생성 실패: {e}") + return None + + +def _tokenize(text: str) -> list[str]: + """BM25용 토크나이징 (기존 kb.py 패턴 재사용).""" + STOPWORDS = {"이", "가", "을", "를", "의", "에", "the", "a", "an", "is"} + tokens = re.split(r'[\s,;:.(){}\[\]<>/\\|&!@#$%^*+=~`\-\'\"]+', text.lower()) + return [t for t in tokens if len(t) >= 2 and t not in STOPWORDS] + + +def _rrf_merge(keyword_results: list, semantic_results: list, k: int = 60) -> list[dict]: + """ + Reciprocal Rank Fusion으로 두 결과 목록 결합. + score = 1/(k + rank_keyword) + 1/(k + rank_semantic) + """ + scores: dict[int, dict] = {} + + for rank, item in enumerate(keyword_results): + doc_id = item["doc_id"] + if doc_id not in scores: + scores[doc_id] = {**item, "rrf_score": 0.0} + scores[doc_id]["rrf_score"] += 1.0 / (k + rank + 1) + + for rank, item in enumerate(semantic_results): + doc_id = item["doc_id"] + if doc_id not in scores: + scores[doc_id] = {**item, "rrf_score": 0.0} + scores[doc_id]["rrf_score"] += 1.0 / (k + rank + 1) + + return sorted(scores.values(), key=lambda x: x["rrf_score"], reverse=True) + + +# ── 엔드포인트 ─────────────────────────────────────────────────────────────── + +@router.post("/search", response_model=List[RAGResult]) +async def hybrid_search( + req: RAGSearchRequest, + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + """하이브리드 검색: BM25(키워드) + 벡터(시맨틱) → RRF 결합.""" + query_tokens = _tokenize(req.query) + + # ── 1. 키워드 기반 검색 (BM25 근사) ───────────────────────────────────── + keyword_hits: list[dict] = [] + if query_tokens: + kbs = await db.execute(select(KBDocument).limit(200)) + kbs_all = kbs.scalars().all() + scored = [] + for doc in kbs_all: + # 간단한 TF 기반 스코어 + text_blob = f"{doc.title or ''} {doc.symptom or ''} {doc.solution or ''}" + doc_tokens = _tokenize(text_blob) + if not doc_tokens: + continue + hit = sum(doc_tokens.count(t) for t in query_tokens) + if hit > 0: + scored.append({ + "doc_id": doc.id, + "title": doc.title, + "excerpt": (doc.symptom or doc.solution or "")[:150], + "score": hit / len(doc_tokens), + "source": "kb", + "tags": json.loads(doc.tags) if doc.tags else [], + }) + keyword_hits = sorted(scored, key=lambda x: x["score"], reverse=True)[:req.top_k * 2] + + # ── 2. 시맨틱 검색 (pgvector) ──────────────────────────────────────────── + semantic_hits: list[dict] = [] + embedding = await _embed(req.query) + if embedding: + try: + vec_str = "[" + ",".join(str(x) for x in embedding) + "]" + # pgvector cosine distance (낮을수록 유사) + raw = await db.execute( + text(""" + SELECT id, title, symptom, solution, tags, + (embedding <=> :vec) AS distance + FROM tb_kb_document + WHERE embedding IS NOT NULL + ORDER BY embedding <=> :vec + LIMIT :lim + """), + {"vec": vec_str, "lim": req.top_k * 2} + ) + for row in raw.fetchall(): + semantic_hits.append({ + "doc_id": row.id, + "title": row.title or "", + "excerpt": (row.symptom or row.solution or "")[:150], + "score": max(0.0, 1.0 - row.distance), + "source": "kb", + "tags": json.loads(row.tags) if row.tags else [], + }) + except Exception as e: + logger.warning(f"pgvector 검색 실패 (키워드만 사용): {e}") + + # ── 3. SR 이력 검색 ────────────────────────────────────────────────────── + if req.include_sr and query_tokens: + sr_rows = await db.execute( + select(SRRequest).where(SRRequest.status == "DONE").order_by( + desc(SRRequest.updated_at) + ).limit(100) + ) + for sr in sr_rows.scalars().all(): + text_blob = f"{sr.title or ''} {sr.description or ''}" + doc_tokens = _tokenize(text_blob) + hit = sum(doc_tokens.count(t) for t in query_tokens) if doc_tokens else 0 + if hit > 0: + keyword_hits.append({ + "doc_id": -(sr.id), # 음수 ID로 SR 구분 + "title": f"[SR-{sr.id}] {sr.title}", + "excerpt": (sr.description or "")[:150], + "score": hit / max(len(doc_tokens), 1), + "source": "sr", + "tags": [sr.category] if sr.category else [], + }) + + # ── 4. RRF 결합 ────────────────────────────────────────────────────────── + merged = _rrf_merge(keyword_hits, semantic_hits) + final = merged[:req.top_k] + + return [ + RAGResult( + doc_id=r["doc_id"], + title=r["title"], + excerpt=r["excerpt"], + score=round(r.get("rrf_score", r.get("score", 0.0)), 4), + source=r["source"], + tags=r.get("tags", []), + ) + for r in final + ] + + +@router.post("/ask") +async def rag_ask( + req: RAGAskRequest, + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + """자연어 질문 → 컨텍스트 검색 → Ollama 답변 생성.""" + # 1. 하이브리드 검색으로 컨텍스트 수집 + search_req = RAGSearchRequest(query=req.question, top_k=req.context_k) + results = await hybrid_search(search_req, db, user) + + context_parts = [] + for r in results: + context_parts.append(f"[{r.source.upper()} {r.doc_id}] {r.title}\n{r.excerpt}") + context = "\n\n".join(context_parts) if context_parts else "관련 문서를 찾지 못했습니다." + + # 2. Ollama 프롬프트 구성 + system_prompt = ( + "당신은 GUARDiA ITSM 운영 어시스턴트입니다. " + "아래 문서만 참조하여 간결하고 정확한 한국어 답변을 제공하세요. " + "문서에 없는 내용은 추측하지 마세요." + ) + user_prompt = f"질문: {req.question}\n\n참조 문서:\n{context}" + + # 3. Ollama 호출 + answer = "Ollama 응답 실패" + try: + async with httpx.AsyncClient(timeout=30) as client: + resp = await client.post( + f"{OLLAMA_URL}/api/generate", + json={ + "model": CHAT_MODEL, + "system": system_prompt, + "prompt": user_prompt, + "stream": False, + } + ) + if resp.status_code == 200: + answer = resp.json().get("response", "응답 없음") + except Exception as e: + logger.error(f"Ollama 호출 실패: {e}") + + return { + "question": req.question, + "answer": answer, + "sources": [{"id": r.doc_id, "title": r.title, "source": r.source} for r in results], + "model": CHAT_MODEL, + } + + +@router.post("/feedback") +async def rag_feedback( + req: RAGFeedbackRequest, + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + """검색 결과 품질 피드백 저장 (Learning Loop 기반 데이터).""" + fb = RAGFeedback( + user_id=user.id, + query=req.query, + doc_id=req.doc_id, + rating=req.rating, + comment=req.comment, + created_at=datetime.utcnow(), + ) + db.add(fb) + await db.commit() + return {"ok": True} + + +@router.get("/stats") +async def rag_stats( + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + """RAG 사용 통계.""" + total_fb = await db.execute(select(func.count(RAGFeedback.id))) + avg_rating = await db.execute(select(func.avg(RAGFeedback.rating))) + return { + "total_feedback": total_fb.scalar() or 0, + "avg_rating": round(avg_rating.scalar() or 0.0, 2), + "embed_model": EMBED_MODEL, + "chat_model": CHAT_MODEL, + } diff --git a/workspace/guardia-itsm/routers/tenant_portal.py b/workspace/guardia-itsm/routers/tenant_portal.py new file mode 100644 index 00000000..a54cf648 --- /dev/null +++ b/workspace/guardia-itsm/routers/tenant_portal.py @@ -0,0 +1,333 @@ +""" +테넌트 셀프서비스 포털 — 기관 관리자가 직접 설정 관리 + +기능: + - 기관 관리자가 직접 사용자 등록/삭제/역할 변경 + - 서버 자산 셀프 등록 + - 알림 수신자·임계값 설정 + - 비밀번호 정책 설정 + - 기관 정보 조회 및 수정 + - 사용량 현황 (쿼터 대비 사용률) + +엔드포인트: + GET /api/portal/me — 내 기관 정보 요약 + GET /api/portal/users — 기관 내 사용자 목록 + POST /api/portal/users — 사용자 초대/등록 + PUT /api/portal/users/{id}/role — 역할 변경 + DELETE /api/portal/users/{id} — 사용자 비활성화 + GET /api/portal/quota — 쿼터 사용량 + PUT /api/portal/settings — 기관 알림·정책 설정 + GET /api/portal/activity — 최근 활동 로그 +""" +from __future__ import annotations + +import logging +import secrets +from datetime import datetime +from typing import List, Optional + +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel, Field +from sqlalchemy import select, func, desc +from sqlalchemy.ext.asyncio import AsyncSession + +from core.auth import get_current_user, require_admin_role +from database import get_db +from models import User, UserRole, AuditLog, Server, SRRequest + +logger = logging.getLogger(__name__) +router = APIRouter(prefix="/api/portal", tags=["Tenant Portal"]) + + +# ── Pydantic 스키마 ────────────────────────────────────────────────────────── + +class PortalUserInvite(BaseModel): + name: str = Field(..., max_length=100) + email: str = Field(..., pattern=r'^[^@]+@[^@]+\.[^@]+$') + role: UserRole = UserRole.ENGINEER + department: Optional[str] = None + +class PortalUserOut(BaseModel): + id: int + name: str + email: str + role: str + department: Optional[str] + is_active: bool + last_login_at: Optional[datetime] + created_at: datetime + +class RoleUpdateRequest(BaseModel): + role: UserRole + +class PortalSettings(BaseModel): + notify_emails: List[str] = [] + sr_alert_threshold: int = Field(10, ge=1, description="SR 적체 경고 임계값") + sla_breach_alert: bool = True + incident_notify: bool = True + weekly_report_email: Optional[str] = None + password_min_length: int = Field(8, ge=6, le=32) + password_expires_days: int = Field(90, ge=30, le=365) + mfa_required: bool = False + +class QuotaInfo(BaseModel): + plan: str + servers_used: int + servers_limit: int + users_used: int + users_limit: int + sr_this_month: int + storage_mb: int + + +# ── 엔드포인트 ─────────────────────────────────────────────────────────────── + +@router.get("/me") +async def my_tenant_info( + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + """내 기관 정보 + 현황 요약.""" + # 사용자 수 + user_count = await db.execute( + select(func.count(User.id)).where( + User.tenant_id == user.tenant_id, + User.is_active == True, + ) + ) + # 서버 수 + server_count = await db.execute( + select(func.count(Server.id)).where(Server.institution_id == user.tenant_id) + ) + # 이번 달 SR 수 + from datetime import date + month_start = date.today().replace(day=1) + sr_count = await db.execute( + select(func.count(SRRequest.id)).where( + SRRequest.created_at >= month_start + ) + ) + # 미처리 SR + open_sr = await db.execute( + select(func.count(SRRequest.id)).where( + SRRequest.status.in_(["OPEN", "IN_PROGRESS"]) + ) + ) + + return { + "tenant_id": user.tenant_id, + "organization": getattr(user, "institution_name", "기관명 미설정"), + "plan": "STANDARD", # 실제: subscription 테이블 참조 + "stats": { + "users": user_count.scalar() or 0, + "servers": server_count.scalar() or 0, + "sr_this_month": sr_count.scalar() or 0, + "open_sr": open_sr.scalar() or 0, + }, + "my_role": user.role.value if hasattr(user.role, 'value') else str(user.role), + } + + +@router.get("/users", response_model=List[PortalUserOut]) +async def list_portal_users( + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + """기관 내 사용자 목록.""" + rows = await db.execute( + select(User).where( + User.tenant_id == user.tenant_id, + ).order_by(desc(User.created_at)) + ) + users = rows.scalars().all() + return [ + PortalUserOut( + id=u.id, + name=u.name, + email=u.email, + role=u.role.value if hasattr(u.role, 'value') else str(u.role), + department=getattr(u, 'department', None), + is_active=u.is_active, + last_login_at=getattr(u, 'last_login_at', None), + created_at=u.created_at, + ) + for u in users + ] + + +@router.post("/users") +async def invite_user( + req: PortalUserInvite, + db: AsyncSession = Depends(get_db), + user: User = Depends(require_admin_role), +): + """사용자 초대 (기관 관리자 전용).""" + # 중복 이메일 확인 + existing = await db.execute(select(User).where(User.email == req.email)) + if existing.scalar_one_or_none(): + raise HTTPException(409, "이미 등록된 이메일입니다") + + # 임시 비밀번호 생성 + temp_pw = secrets.token_urlsafe(12) + + from passlib.context import CryptContext + pwd_ctx = CryptContext(schemes=["bcrypt"], deprecated="auto") + + new_user = User( + name=req.name, + email=req.email, + hashed_password=pwd_ctx.hash(temp_pw), + role=req.role, + tenant_id=user.tenant_id, + is_active=True, + created_at=datetime.utcnow(), + ) + db.add(new_user) + + # 감사 로그 + log = AuditLog( + user_id=user.id, + action="USER_INVITED", + detail=f"신규 사용자 초대: {req.email} ({req.role})", + created_at=datetime.utcnow(), + ) + db.add(log) + await db.commit() + await db.refresh(new_user) + + logger.info(f"사용자 초대: {req.email} by admin {user.email}") + + return { + "ok": True, + "user_id": new_user.id, + "temp_password": temp_pw, + "message": f"{req.email}에 임시 비밀번호를 발급했습니다. 최초 로그인 시 변경 필요.", + } + + +@router.put("/users/{target_id}/role") +async def update_user_role( + target_id: int, + req: RoleUpdateRequest, + db: AsyncSession = Depends(get_db), + user: User = Depends(require_admin_role), +): + """사용자 역할 변경.""" + target = await db.execute( + select(User).where( + User.id == target_id, + User.tenant_id == user.tenant_id, + ) + ) + target_user = target.scalar_one_or_none() + if not target_user: + raise HTTPException(404, "사용자를 찾을 수 없습니다") + + old_role = target_user.role + target_user.role = req.role + + log = AuditLog( + user_id=user.id, + action="ROLE_CHANGED", + detail=f"역할 변경: {target_user.email} {old_role} → {req.role}", + created_at=datetime.utcnow(), + ) + db.add(log) + await db.commit() + return {"ok": True, "user_id": target_id, "new_role": req.role} + + +@router.delete("/users/{target_id}") +async def deactivate_user( + target_id: int, + db: AsyncSession = Depends(get_db), + user: User = Depends(require_admin_role), +): + """사용자 비활성화 (삭제 대신 비활성화로 감사 추적 유지).""" + if target_id == user.id: + raise HTTPException(400, "자기 자신을 비활성화할 수 없습니다") + + target = await db.execute( + select(User).where( + User.id == target_id, + User.tenant_id == user.tenant_id, + ) + ) + target_user = target.scalar_one_or_none() + if not target_user: + raise HTTPException(404, "사용자를 찾을 수 없습니다") + + target_user.is_active = False + log = AuditLog( + user_id=user.id, + action="USER_DEACTIVATED", + detail=f"사용자 비활성화: {target_user.email}", + created_at=datetime.utcnow(), + ) + db.add(log) + await db.commit() + return {"ok": True} + + +@router.get("/quota", response_model=QuotaInfo) +async def get_quota( + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + """현재 쿼터 사용량 조회.""" + # STANDARD 플랜 기본값 (실제: subscription 테이블 참조) + PLAN_LIMITS = {"STANDARD": {"servers": 200, "users": 100}} + limits = PLAN_LIMITS.get("STANDARD", {"servers": 20, "users": 10}) + + server_used = (await db.execute( + select(func.count(Server.id)).where(Server.institution_id == user.tenant_id) + )).scalar() or 0 + + user_used = (await db.execute( + select(func.count(User.id)).where( + User.tenant_id == user.tenant_id, User.is_active == True + ) + )).scalar() or 0 + + from datetime import date + sr_this_month = (await db.execute( + select(func.count(SRRequest.id)).where( + SRRequest.created_at >= date.today().replace(day=1) + ) + )).scalar() or 0 + + return QuotaInfo( + plan="STANDARD", + servers_used=server_used, + servers_limit=limits["servers"], + users_used=user_used, + users_limit=limits["users"], + sr_this_month=sr_this_month, + storage_mb=0, # 추후 구현 + ) + + +@router.get("/activity") +async def recent_activity( + limit: int = 20, + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + """기관 내 최근 활동 로그.""" + rows = await db.execute( + select(AuditLog, User.name.label("actor_name")).join( + User, AuditLog.user_id == User.id, isouter=True + ).where( + User.tenant_id == user.tenant_id, + ).order_by(desc(AuditLog.created_at)).limit(limit) + ) + return [ + { + "id": row.AuditLog.id, + "action": row.AuditLog.action, + "detail": row.AuditLog.detail, + "actor": row.actor_name, + "created_at": row.AuditLog.created_at, + } + for row in rows.all() + ]