feat(expansion): GUARDiA v3 — 6 P1 routers + 7 DB tables

라우터 (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 <noreply@anthropic.com>
This commit is contained in:
DESKTOP-TKLFCPR\ython 2026-06-02 00:49:33 +09:00
parent 373ffb9536
commit e7dc273b36
8 changed files with 2218 additions and 0 deletions

View File

@ -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")

View File

@ -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)

View File

@ -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()
]

View File

@ -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
],
}

View File

@ -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,
)

View File

@ -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(),
}

View File

@ -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,
}

View File

@ -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()
]