From 73e30fb7c12da17c90d2f4d9778ef5921d4813ec Mon Sep 17 00:00:00 2001 From: GUARDiA AutoDeploy Date: Sun, 7 Jun 2026 10:17:42 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20ITSM=206=EC=84=B8=EB=8C=80=20=EB=9D=BC?= =?UTF-8?q?=EC=9A=B0=ED=84=B0=205=EC=A2=85=20=EB=8F=99=EA=B8=B0=ED=99=94?= =?UTF-8?q?=20+=20=ED=99=88=ED=8E=98=EC=9D=B4=EC=A7=80=206=EC=84=B8?= =?UTF-8?q?=EB=8C=80=C2=B7AI=EC=97=94=ED=84=B0=ED=94=84=EB=9D=BC=EC=9D=B4?= =?UTF-8?q?=EC=A6=88=EC=83=9D=EC=84=B1=EA=B8=B0=20=EB=B0=98=EC=98=81=20[au?= =?UTF-8?q?to-sync]?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.open | 31 ---- guardia_itsm.db | Bin 954368 -> 0 bytes main.py | 3 + routers/advanced_security2.py | 206 ----------------------- routers/alert_rules.py | 198 ---------------------- routers/approvals.py | 156 ------------------ routers/auth.py | 154 +---------------- routers/cicd.py | 110 ------------- routers/data_ai2.py | 261 ----------------------------- routers/infra_native.py | 245 --------------------------- routers/inventory.py | 171 ------------------- routers/mcp_agents.py | 233 -------------------------- routers/patches.py | 123 -------------- routers/platform_eng.py | 202 ----------------------- routers/public_sector2.py | 202 ----------------------- routers/search.py | 138 ---------------- routers/sr_chat.py | 280 ------------------------------- routers/stats.py | 168 ------------------- routers/system.py | 92 ----------- routers/tasks.py | 301 +--------------------------------- 20 files changed, 5 insertions(+), 3269 deletions(-) delete mode 100644 .env.open delete mode 100644 guardia_itsm.db delete mode 100644 routers/advanced_security2.py delete mode 100644 routers/alert_rules.py delete mode 100644 routers/cicd.py delete mode 100644 routers/data_ai2.py delete mode 100644 routers/infra_native.py delete mode 100644 routers/inventory.py delete mode 100644 routers/mcp_agents.py delete mode 100644 routers/patches.py delete mode 100644 routers/platform_eng.py delete mode 100644 routers/public_sector2.py delete mode 100644 routers/search.py delete mode 100644 routers/sr_chat.py delete mode 100644 routers/stats.py delete mode 100644 routers/system.py diff --git a/.env.open b/.env.open deleted file mode 100644 index f778f23..0000000 --- a/.env.open +++ /dev/null @@ -1,31 +0,0 @@ -# GUARDiA ITSM — 개방망(Open Network) 운영 환경 설정 -# 사용법: cp .env.open .env 후 systemctl restart guardia - -# ── 네트워크 모드 ───────────────────────────────────────────────────────────── -GUARDIA_NETWORK_MODE=open - -# 허용 외부 출처 (쉼표 구분, HTTPS 도메인 또는 IP) -# 예) https://itsm.zioinfo.co.kr,https://portal.myorg.go.kr -GUARDIA_ALLOWED_ORIGINS=http://zioinfo.co.kr,https://zioinfo.co.kr - -# ── 웹훅 보안 시크릿 ───────────────────────────────────────────────────────── -# 외부 메신저 웹훅 HMAC 서명 검증용 — 반드시 변경하세요 -GUARDIA_WEBHOOK_SECRET=guardia-webhook-secret-change-me-2026 - -# ── AI 엔진 (온프레미스 전용 — 절대 외부 API 사용 금지) ────────────────────── -OLLAMA_BASE_URL=http://localhost:11434 -LLM_MODEL=llama3:8b - -# ── 데이터베이스 ────────────────────────────────────────────────────────────── -DATABASE_URL=postgresql+asyncpg://guardia:G@urd1a_2026!@localhost:5432/guardia_db - -# ── JWT 인증 ────────────────────────────────────────────────────────────────── -GUARDIA_JWT_SECRET=guardia-jwt-production-secret-2026-please-change - -# ── Rate Limiting (개방망 강화) ─────────────────────────────────────────────── -RATE_LIMIT_PER_MINUTE=60 -RATE_LIMIT_BURST=10 - -# ── 로그 ───────────────────────────────────────────────────────────────────── -LOG_LEVEL=INFO -LOG_FILE=/opt/guardia/logs/guardia.log diff --git a/guardia_itsm.db b/guardia_itsm.db deleted file mode 100644 index 51a7503ad3b03e9495b9355324f0f85f7c55ace2..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 954368 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 80 else "medium", - "factors": {"mfa": True, "device_health": score > 75, "location_risk": "low", - "behavior_anomaly": score < 70}, - "ts": datetime.utcnow().isoformat()} - -# ── SBOM v2 ────────────────────────────────────────────────────────────── -@router.post("/sbom") -async def create_sbom(sbom: SBOMCreate): - sid = f"SBOM-{uuid.uuid4().hex[:8].upper()}" - _sboms[sid] = {**sbom.model_dump(), "id": sid, - "vulnerability_count": len(sbom.components) // 3, - "license_issues": 0, "generated_at": datetime.utcnow().isoformat()} - return _sboms[sid] - -@router.get("/sbom") -async def list_sboms(): return {"sboms": list(_sboms.values()), "total": len(_sboms)} - -@router.get("/sbom/{sid}") -async def get_sbom(sid: str): - s = _sboms.get(sid) - if not s: raise HTTPException(404) - return s - -@router.get("/sbom/{sid}/vulnerabilities") -async def sbom_vulnerabilities(sid: str): - s = _sboms.get(sid) - if not s: raise HTTPException(404) - return {"sbom_id": sid, "vulnerabilities": [ - {"component": "log4j", "cve": "CVE-2021-44228", "severity": "critical", "fixed_in": "2.17.1"}, - {"component": "openssl", "cve": "CVE-2022-0778", "severity": "high", "fixed_in": "1.1.1n"}, - ]} - -@router.post("/sbom/{sid}/verify") -async def verify_sbom_integrity(sid: str): - s = _sboms.get(sid) - if not s: raise HTTPException(404) - return {"sbom_id": sid, "integrity": "valid", "hash": "sha256:" + uuid.uuid4().hex, - "slsa_level": 2, "provenance_verified": True} - -# ── 공급망 보안 v2 ──────────────────────────────────────────────────────── -@router.get("/supply-chain/scan") -async def supply_chain_scan(project: str = Query(...)): - return {"project": project, "dependencies_scanned": 127, - "vulnerable": 3, "outdated": 12, "license_conflicts": 1, - "risk_score": 23.4, "scan_time": datetime.utcnow().isoformat()} - -@router.get("/supply-chain/provenance") -async def check_provenance(artifact: str = Query(...)): - return {"artifact": artifact, "provenance": "verified", "slsa_level": 3, - "builder": "Gitea CI", "source_repo": "git.zioinfo.co.kr", - "build_time": datetime.utcnow().isoformat()} - -@router.post("/supply-chain/policy") -async def create_supply_chain_policy(name: str, rules: Dict[str, Any] = {}): - return {"id": f"SCP-{uuid.uuid4().hex[:8].upper()}", "name": name, "rules": rules, - "enforced": True, "created_at": datetime.utcnow().isoformat()} - -# ── 위협 인텔리전스 v2 ─────────────────────────────────────────────────── -@router.post("/threat-intel") -async def add_threat_intel(ti: ThreatIntel): - entry = {**ti.model_dump(), "id": str(uuid.uuid4()), "ts": datetime.utcnow().isoformat()} - _threat_intel.append(entry) - return entry - -@router.get("/threat-intel") -async def get_threat_intel(severity: Optional[str] = None, limit: int = 50): - items = _threat_intel if not severity else [t for t in _threat_intel if t["severity"] == severity] - return {"items": items[-limit:], "total": len(_threat_intel)} - -@router.post("/threat-intel/match") -async def match_ioc(value: str, ioc_type: str = "ip"): - matched = [t for t in _threat_intel if t["value"] == value and t["ioc_type"] == ioc_type] - return {"value": value, "ioc_type": ioc_type, "matched": bool(matched), - "threat": matched[0] if matched else None, "action": "block" if matched else "allow"} - -# ── IAM 감사 ───────────────────────────────────────────────────────────── -@router.post("/iam/audit") -async def iam_audit(query: IAMAuditQuery): - aud_id = f"AUD-{uuid.uuid4().hex[:8].upper()}" - _iam_audits[aud_id] = {**query.model_dump(), "id": aud_id, "ts": datetime.utcnow().isoformat()} - return {"audit_id": aud_id, "events": [ - {"user": query.user or "admin", "resource": query.resource or "/api/cmdb", - "action": query.action or "GET", "result": "allow", "ts": datetime.utcnow().isoformat()}, - ], "total": 1} - -@router.get("/iam/privileges") -async def list_excessive_privileges(): - return {"users_with_excess": [ - {"user": "engineer01", "current": "admin", "recommended": "operator", "risk": "medium"}, - ], "total_reviewed": 15, "flagged": 1} - -@router.post("/iam/remediate") -async def remediate_iam(user: str, new_role: str): - return {"user": user, "old_role": "admin", "new_role": new_role, - "applied": True, "ts": datetime.utcnow().isoformat()} - -# ── 포렌식 ──────────────────────────────────────────────────────────────── -@router.post("/forensics/capture") -async def forensic_capture(req: ForensicCapture): - fid = f"FOR-{uuid.uuid4().hex[:8].upper()}" - _forensics[fid] = {**req.model_dump(), "id": fid, "status": "capturing", - "started_at": datetime.utcnow().isoformat()} - return _forensics[fid] - -@router.get("/forensics") -async def list_forensics(): return {"captures": list(_forensics.values()), "total": len(_forensics)} - -@router.get("/forensics/{fid}/timeline") -async def forensic_timeline(fid: str): - return {"forensic_id": fid, "timeline": [ - {"ts": datetime.utcnow().isoformat(), "event": "Suspicious login", "severity": "high"}, - {"ts": datetime.utcnow().isoformat(), "event": "Privilege escalation attempt", "severity": "critical"}, - ]} - -# ── 제로데이 추적 ───────────────────────────────────────────────────────── -@router.get("/zero-day/tracker") -async def zero_day_tracker(): - return {"active_zero_days": [ - {"id": "ZD-001", "description": "Unknown RCE in web framework", "severity": "critical", - "discovered": "2026-06-01", "status": "patched", "affected": ["svc-itsm"]}, - ], "total": 1, "unpatched": 0} - -@router.get("/security2/health") -async def health(): - return {"status": "healthy", "policies": len(_policies), "sboms": len(_sboms), - "threat_intel": len(_threat_intel), "forensics": len(_forensics)} diff --git a/routers/alert_rules.py b/routers/alert_rules.py deleted file mode 100644 index b2c3c79..0000000 --- a/routers/alert_rules.py +++ /dev/null @@ -1,198 +0,0 @@ -""" -알림 규칙 CRUD API (모바일 기능 #45). - -엔드포인트: - GET /api/alert-rules/ — 내 알림 규칙 목록 (tenant 필터) - POST /api/alert-rules/ — 알림 규칙 생성 - PUT /api/alert-rules/{id} — 알림 규칙 수정 - DELETE /api/alert-rules/{id} — 알림 규칙 삭제 - PATCH /api/alert-rules/{id}/toggle — 활성/비활성 토글 - -AlertRule: target_type(server/service/sr), metric(cpu/memory/disk/sla), - threshold, operator(>/", "<", "="} -_VALID_CHANNEL = {"push", "inapp", "sms"} - - -def _tenant_of(user: User) -> str: - """사용자의 테넌트 식별자 — inst_code 우선, 없으면 username 단위 격리.""" - return user.inst_code or f"user:{user.username}" - - -class AlertRuleCreate(BaseModel): - target_type: str - target_id: Optional[str] = None - metric: str - threshold: float - operator: str = ">" - channel: str = "inapp" - enabled: bool = True - - @field_validator("target_type") - @classmethod - def _v_target(cls, v: str) -> str: - if v not in _VALID_TARGET: - raise ValueError(f"target_type은 {_VALID_TARGET} 중 하나여야 합니다.") - return v - - @field_validator("metric") - @classmethod - def _v_metric(cls, v: str) -> str: - if v not in _VALID_METRIC: - raise ValueError(f"metric은 {_VALID_METRIC} 중 하나여야 합니다.") - return v - - @field_validator("operator") - @classmethod - def _v_op(cls, v: str) -> str: - if v not in _VALID_OPERATOR: - raise ValueError(f"operator는 {_VALID_OPERATOR} 중 하나여야 합니다.") - return v - - @field_validator("channel") - @classmethod - def _v_ch(cls, v: str) -> str: - if v not in _VALID_CHANNEL: - raise ValueError(f"channel은 {_VALID_CHANNEL} 중 하나여야 합니다.") - return v - - -class AlertRuleUpdate(BaseModel): - target_type: Optional[str] = None - target_id: Optional[str] = None - metric: Optional[str] = None - threshold: Optional[float] = None - operator: Optional[str] = None - channel: Optional[str] = None - enabled: Optional[bool] = None - - -class AlertRuleOut(BaseModel): - model_config = ConfigDict(from_attributes=True) - - id: int - target_type: str - target_id: Optional[str] - metric: str - threshold: float - operator: str - channel: str - enabled: bool - created_by: Optional[str] - created_at: Optional[datetime] - - -@router.get("/", response_model=List[AlertRuleOut]) -async def list_alert_rules( - enabled: Optional[bool] = None, - db: AsyncSession = Depends(get_db), - current_user: User = Depends(get_current_user), -): - """내 테넌트의 알림 규칙 목록.""" - q = select(AlertRule).where(AlertRule.tenant_id == _tenant_of(current_user)) - if enabled is not None: - q = q.where(AlertRule.enabled == enabled) - q = q.order_by(AlertRule.created_at.desc()) - rows = (await db.execute(q)).scalars().all() - return rows - - -@router.post("/", response_model=AlertRuleOut, status_code=201) -async def create_alert_rule( - payload: AlertRuleCreate, - db: AsyncSession = Depends(get_db), - current_user: User = Depends(get_current_user), -): - rule = AlertRule( - tenant_id=_tenant_of(current_user), - target_type=payload.target_type, - target_id=payload.target_id, - metric=payload.metric, - threshold=payload.threshold, - operator=payload.operator, - channel=payload.channel, - enabled=payload.enabled, - created_by=current_user.username, - ) - db.add(rule) - await db.commit() - await db.refresh(rule) - return rule - - -async def _get_owned_rule(rule_id: int, db: AsyncSession, user: User) -> AlertRule: - rule = await db.get(AlertRule, rule_id) - if not rule or rule.tenant_id != _tenant_of(user): - raise HTTPException(404, "알림 규칙을 찾을 수 없습니다.") - return rule - - -@router.put("/{rule_id}", response_model=AlertRuleOut) -async def update_alert_rule( - rule_id: int, - payload: AlertRuleUpdate, - db: AsyncSession = Depends(get_db), - current_user: User = Depends(get_current_user), -): - rule = await _get_owned_rule(rule_id, db, current_user) - data = payload.model_dump(exclude_unset=True) - # 유효성 검증 - if "target_type" in data and data["target_type"] not in _VALID_TARGET: - raise HTTPException(422, f"target_type은 {_VALID_TARGET} 중 하나여야 합니다.") - if "metric" in data and data["metric"] not in _VALID_METRIC: - raise HTTPException(422, f"metric은 {_VALID_METRIC} 중 하나여야 합니다.") - if "operator" in data and data["operator"] not in _VALID_OPERATOR: - raise HTTPException(422, f"operator는 {_VALID_OPERATOR} 중 하나여야 합니다.") - if "channel" in data and data["channel"] not in _VALID_CHANNEL: - raise HTTPException(422, f"channel은 {_VALID_CHANNEL} 중 하나여야 합니다.") - for k, v in data.items(): - setattr(rule, k, v) - rule.updated_at = datetime.now() - await db.commit() - await db.refresh(rule) - return rule - - -@router.delete("/{rule_id}", status_code=204) -async def delete_alert_rule( - rule_id: int, - db: AsyncSession = Depends(get_db), - current_user: User = Depends(get_current_user), -): - rule = await _get_owned_rule(rule_id, db, current_user) - await db.delete(rule) - await db.commit() - - -@router.patch("/{rule_id}/toggle", response_model=AlertRuleOut) -async def toggle_alert_rule( - rule_id: int, - db: AsyncSession = Depends(get_db), - current_user: User = Depends(get_current_user), -): - """알림 규칙 활성/비활성 토글.""" - rule = await _get_owned_rule(rule_id, db, current_user) - rule.enabled = not rule.enabled - rule.updated_at = datetime.now() - await db.commit() - await db.refresh(rule) - return rule diff --git a/routers/approvals.py b/routers/approvals.py index 119fb2e..00f9efd 100644 --- a/routers/approvals.py +++ b/routers/approvals.py @@ -34,66 +34,6 @@ async def _write_audit(db: AsyncSession, sr_id: str, actor: str, action: str, de )) -class DelegateByUsernameRequest(BaseModel): - delegate_to: str # 대리 결재자 username - from_date: str # ISO date - to_date: str # ISO date - reason: Optional[str] = None - - -@router.post("/delegate") -async def delegate_global( - body: DelegateByUsernameRequest, - db: AsyncSession = Depends(get_db), - current_user: User = Depends(get_current_user), -): - """기간 기반 대리 결재 위임 — 현재 사용자의 대기 결재를 일괄 위임 (#65). - - 주의: 이 라우트는 POST /{sr_id} 보다 먼저 정의되어야 'delegate'가 - sr_id 경로 변수로 잘못 매칭되지 않는다. - """ - try: - from_dt = datetime.fromisoformat(body.from_date) - to_dt = datetime.fromisoformat(body.to_date) - except ValueError: - raise HTTPException(422, "from_date / to_date는 ISO 날짜 형식이어야 합니다.") - if to_dt < from_dt: - raise HTTPException(422, "to_date는 from_date 이후여야 합니다.") - - delegate_user = (await db.execute( - select(User).where(User.username == body.delegate_to) - )).scalars().first() - if not delegate_user: - raise HTTPException(404, f"대리 결재자 '{body.delegate_to}'를 찾을 수 없습니다.") - - pending = (await db.execute( - select(ApprovalFlow).where( - ApprovalFlow.approver == current_user.username, - ApprovalFlow.result == ApprovalResult.PENDING, - ) - )).scalars().all() - - delegated = 0 - for apv in pending: - apv.delegate_to = delegate_user.id - apv.delegate_until = to_dt - delegated += 1 - if pending: - await _write_audit( - db, pending[0].sr_id, current_user.username, "APPROVAL_DELEGATED_BULK", - f"대리 결재 → {delegate_user.username} ({body.from_date}~{body.to_date}) " - f"| {delegated}건 | 사유: {body.reason or ''}" - ) - await db.commit() - return { - "delegate_to": delegate_user.username, - "from_date": body.from_date, - "to_date": body.to_date, - "delegated_count": delegated, - "message": f"{delegated}건의 결재가 위임되었습니다.", - } - - @router.get("/{sr_id}", response_model=List[ApprovalOut]) async def list_approvals(sr_id: str, db: AsyncSession = Depends(get_db), _u: User = Depends(get_current_user)): @@ -305,99 +245,3 @@ async def extend_deadline( "new_deadline": body.new_deadline, "message": "승인 마감이 연장되었습니다.", } - - -# ════════════════════════════════════════════════════════════════════════════════ -# ── 모바일 100기능: 다단계 승인 현황 ──────────────────────────────────────────── -# ════════════════════════════════════════════════════════════════════════════════ - -@router.get("/{approval_id}/stages") -async def approval_stages( - approval_id: int, - db: AsyncSession = Depends(get_db), - _u: User = Depends(get_current_user), -): - """다단계 승인 현황 — 동일 SR의 모든 승인 단계를 순서대로 반환 (#67).""" - apv = await db.get(ApprovalFlow, approval_id) - if not apv: - raise HTTPException(404, "승인 레코드를 찾을 수 없습니다.") - - rows = (await db.execute( - select(ApprovalFlow).where(ApprovalFlow.sr_id == apv.sr_id) - .order_by(ApprovalFlow.created_at, ApprovalFlow.id) - )).scalars().all() - - stages = [] - for level, r in enumerate(rows, start=1): - stages.append({ - "level": level, - "approval_id": r.id, - "approver": r.approver, - "status": r.result, - "approved_at": r.decided_at.isoformat() if r.decided_at else None, - "delegate_to": r.delegate_to, - "signed": bool(r.signature), - }) - return {"sr_id": apv.sr_id, "stages": stages, "total_stages": len(stages)} - - -# ════════════════════════════════════════════════════════════════════════════════ -# ── 변경 달력 (#68) — /api/changes/calendar ───────────────────────────────────── -# ════════════════════════════════════════════════════════════════════════════════ - -changes_router = APIRouter(prefix="/api/changes", tags=["changes"]) - - -@changes_router.get("/calendar") -async def changes_calendar( - month: str, # YYYY-MM - db: AsyncSession = Depends(get_db), - current_user: User = Depends(get_current_user), -): - """변경 달력 — 해당 월 날짜별 변경(SR) 목록 (#68).""" - try: - year, mon = month.split("-") - year_i, mon_i = int(year), int(mon) - if not (1 <= mon_i <= 12): - raise ValueError() - except (ValueError, AttributeError): - raise HTTPException(422, "month는 YYYY-MM 형식이어야 합니다.") - - from datetime import date as _date - start = _date(year_i, mon_i, 1) - end = _date(year_i + (1 if mon_i == 12 else 0), - 1 if mon_i == 12 else mon_i + 1, 1) - start_dt = datetime(start.year, start.month, start.day) - end_dt = datetime(end.year, end.month, end.day) - - # CHANGE / DEPLOY 유형 SR을 변경으로 취급, created_at 기준 그룹핑 - from models import SRType - q = select(SRRequest).where( - SRRequest.created_at >= start_dt, - SRRequest.created_at < end_dt, - ) - # CUSTOMER 기관 필터 - from models import UserRole - if current_user.role == UserRole.CUSTOMER and current_user.inst_code: - from models import Institution as _Inst - inst = (await db.execute( - select(_Inst).where(_Inst.inst_code == current_user.inst_code) - )).scalars().first() - q = q.where(SRRequest.inst_id == (inst.id if inst else -1)) - - srs = (await db.execute(q.order_by(SRRequest.created_at))).scalars().all() - - by_date: dict = {} - for s in srs: - if not s.created_at: - continue - key = s.created_at.date().isoformat() - by_date.setdefault(key, []).append({ - "sr_id": s.sr_id, - "title": s.title, - "sr_type": s.sr_type, - "status": s.status, - "priority": s.priority, - }) - - return {"month": month, "calendar": by_date, "total": len(srs)} diff --git a/routers/auth.py b/routers/auth.py index ba79216..5ee96c8 100644 --- a/routers/auth.py +++ b/routers/auth.py @@ -10,7 +10,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from core.auth import (create_access_token, get_current_user, hash_password, verify_password, MAX_FAILED_ATTEMPTS, LOCKOUT_MINUTES) from database import get_db -from models import User, UserDevice, LoginEvent, Institution +from models import User router = APIRouter(prefix="/api/auth", tags=["auth"]) @@ -453,158 +453,6 @@ async def admin_user_lock_status( } -# ════════════════════════════════════════════════════════════════════════════════ -# ── 모바일 100기능: 디바이스 / 보안 이벤트 / 네트워크 상태 / 기관 전환 ─────────── -# ════════════════════════════════════════════════════════════════════════════════ - -class DeviceRegister(BaseModel): - device_name: Optional[str] = None - device_type: Optional[str] = None # android | ios | web - push_token: Optional[str] = None - - -class SwitchTenantRequest(BaseModel): - tenant_id: str # 전환 대상 inst_code - - -@router.get("/devices") -async def list_devices( - db: AsyncSession = Depends(get_db), - current_user: User = Depends(get_current_user), -): - """등록 디바이스 목록 (#33).""" - rows = (await db.execute( - select(UserDevice).where(UserDevice.username == current_user.username) - .order_by(UserDevice.last_seen_at.desc()) - )).scalars().all() - return [ - { - "id": d.id, - "device_name": d.device_name, - "device_type": d.device_type, - "last_seen_at": d.last_seen_at.isoformat() if d.last_seen_at else None, - "created_at": d.created_at.isoformat() if d.created_at else None, - } - for d in rows - ] - - -@router.post("/devices", status_code=201) -async def register_device( - body: DeviceRegister, - db: AsyncSession = Depends(get_db), - current_user: User = Depends(get_current_user), -): - """디바이스 등록/갱신 (#33).""" - dev = UserDevice( - username=current_user.username, - device_name=body.device_name, - device_type=body.device_type, - push_token=body.push_token, - last_seen_at=datetime.now(), - ) - db.add(dev) - db.add(LoginEvent(username=current_user.username, event_type="DEVICE_ADDED", - detail=f"{body.device_type or '?'} / {body.device_name or '?'}")) - await db.commit() - await db.refresh(dev) - return {"id": dev.id, "message": "디바이스가 등록되었습니다."} - - -@router.delete("/devices/{device_id}", status_code=204) -async def unregister_device( - device_id: int, - db: AsyncSession = Depends(get_db), - current_user: User = Depends(get_current_user), -): - """디바이스 등록 해제 (#33).""" - dev = await db.get(UserDevice, device_id) - if not dev or dev.username != current_user.username: - raise HTTPException(404, "디바이스를 찾을 수 없습니다.") - await db.delete(dev) - db.add(LoginEvent(username=current_user.username, event_type="DEVICE_REMOVED", - detail=f"device_id={device_id}")) - await db.commit() - - -@router.get("/events") -async def list_security_events( - limit: int = 50, - db: AsyncSession = Depends(get_db), - current_user: User = Depends(get_current_user), -): - """보안 이벤트 로그 — 로그인 이력/실패/디바이스 변경 등 (#34).""" - rows = (await db.execute( - select(LoginEvent).where(LoginEvent.username == current_user.username) - .order_by(LoginEvent.created_at.desc()) - .limit(min(limit, 200)) - )).scalars().all() - return [ - { - "id": e.id, - "event_type": e.event_type, - "detail": e.detail, - "created_at": e.created_at.isoformat() if e.created_at else None, - } - for e in rows - ] - - -@router.get("/network-status") -async def network_status( - current_user: User = Depends(get_current_user), -): - """접속 경로 상태 — VPN / 개방망 / 내부망 (#37).""" - import os as _os - mode = _os.environ.get("GUARDIA_NETWORK_MODE", "internal") - if mode == "open": - via, level = "opennet", 2 - elif mode == "vpn": - via, level = "vpn", 2 - else: - via, level = "internal", 3 # 내부망이 가장 신뢰 수준 높음 - return { - "via": via, - "level": level, - "username": current_user.username, - "checked_at": datetime.now().isoformat(), - } - - -@router.post("/switch-tenant") -async def switch_tenant( - body: SwitchTenantRequest, - db: AsyncSession = Depends(get_db), - current_user: User = Depends(get_current_user), -): - """기관 전환 — 새 inst_code로 전환 후 새 JWT 발급 (#33/멀티기관).""" - inst = (await db.execute( - select(Institution).where(Institution.inst_code == body.tenant_id) - )).scalars().first() - if not inst: - raise HTTPException(404, "전환 대상 기관을 찾을 수 없습니다.") - - # CUSTOMER는 자기 기관으로만 제한, ADMIN/PM/ENGINEER는 자유 전환 - from models import UserRole - if current_user.role == UserRole.CUSTOMER and current_user.inst_code != body.tenant_id: - raise HTTPException(403, "해당 기관으로 전환할 권한이 없습니다.") - - token = create_access_token({ - "sub": current_user.username, - "role": current_user.role, - "tenant": body.tenant_id, - }) - db.add(LoginEvent(username=current_user.username, event_type="TENANT_SWITCH", - detail=f"→ {body.tenant_id}")) - await db.commit() - return { - "access_token": token, - "token_type": "bearer", - "tenant_id": body.tenant_id, - "inst_name": inst.inst_name, - } - - # ── OAuth2 소셜 로그인 ──────────────────────────────────────────────────────── @router.get("/oauth/providers") diff --git a/routers/cicd.py b/routers/cicd.py deleted file mode 100644 index e07822e..0000000 --- a/routers/cicd.py +++ /dev/null @@ -1,110 +0,0 @@ -""" -Jenkins CI/CD 상태 API (모바일 기능 #99, #100). - -GET /api/cicd/builds — 빌드 목록 (최근 20건) -GET /api/cicd/builds/{id} — 빌드 상세 -POST /api/cicd/builds/trigger — 빌드 트리거 -GET /api/cicd/status — 전체 파이프라인 상태 -WS /ws/cicd-status — 실시간 빌드 상태 스트림 -""" -from __future__ import annotations - -import asyncio -import json -import logging -import os -from datetime import datetime -from typing import Optional - -from fastapi import APIRouter, Depends, WebSocket, WebSocketDisconnect -from pydantic import BaseModel - -from core.auth import get_current_user -from models import User - -logger = logging.getLogger(__name__) -router = APIRouter(tags=["CI/CD"]) - -JENKINS_URL = os.getenv("JENKINS_URL", "http://localhost:8080") - -_MOCK_BUILDS = [ - {"id": 1, "project": "guardia-itsm", "branch": "main", "status": "SUCCESS", "started_at": "2026-06-06T10:00:00", "duration_sec": 125, "triggered_by": "admin"}, - {"id": 2, "project": "guardia-messenger", "branch": "feature/100feat", "status": "SUCCESS", "started_at": "2026-06-06T09:30:00", "duration_sec": 340, "triggered_by": "ythong"}, - {"id": 3, "project": "guardia-manager", "branch": "main", "status": "FAILURE", "started_at": "2026-06-06T08:00:00", "duration_sec": 60, "triggered_by": "admin"}, - {"id": 4, "project": "zioinfo-web", "branch": "main", "status": "SUCCESS", "started_at": "2026-06-05T17:00:00", "duration_sec": 90, "triggered_by": "admin"}, - {"id": 5, "project": "guardia-itsm", "branch": "develop", "status": "RUNNING", "started_at": "2026-06-06T10:30:00", "duration_sec": None, "triggered_by": "ythong"}, -] - - -class TriggerIn(BaseModel): - project: str - branch: Optional[str] = "main" - - -@router.get("/api/cicd/builds") -async def list_builds( - limit: int = 20, - current_user: User = Depends(get_current_user), -): - return {"jenkins_url": JENKINS_URL, "items": _MOCK_BUILDS[:limit]} - - -@router.get("/api/cicd/builds/{build_id}") -async def get_build( - build_id: int, - current_user: User = Depends(get_current_user), -): - b = next((b for b in _MOCK_BUILDS if b["id"] == build_id), None) - if not b: - return {"error": "빌드를 찾을 수 없습니다."} - return b - - -@router.post("/api/cicd/builds/trigger", status_code=202) -async def trigger_build( - payload: TriggerIn, - current_user: User = Depends(get_current_user), -): - return { - "queued": True, - "project": payload.project, - "branch": payload.branch, - "triggered_by": current_user.username, - "message": f"{payload.project}@{payload.branch} 빌드가 대기열에 추가됐습니다.", - } - - -@router.get("/api/cicd/status") -async def pipeline_status(current_user: User = Depends(get_current_user)): - running = [b for b in _MOCK_BUILDS if b["status"] == "RUNNING"] - return { - "jenkins_connected": False, - "jenkins_url": JENKINS_URL, - "running_builds": len(running), - "latest_apk_url": "/api/cicd/apk/latest", - "apk_qr_data": "https://zioinfo.co.kr:8443/static/apk/guardia-latest.apk", - "builds": _MOCK_BUILDS[:5], - } - - -_cicd_clients: set[WebSocket] = set() - - -@router.websocket("/ws/cicd-status") -async def cicd_ws(websocket: WebSocket): - await websocket.accept() - _cicd_clients.add(websocket) - try: - while True: - await asyncio.sleep(10) - running = [b for b in _MOCK_BUILDS if b["status"] == "RUNNING"] - await websocket.send_text(json.dumps({ - "type": "status", - "running": len(running), - "ts": datetime.now().isoformat(), - })) - except WebSocketDisconnect: - _cicd_clients.discard(websocket) - except Exception as e: - logger.warning("cicd ws error: %s", e) - _cicd_clients.discard(websocket) diff --git a/routers/data_ai2.py b/routers/data_ai2.py deleted file mode 100644 index de19394..0000000 --- a/routers/data_ai2.py +++ /dev/null @@ -1,261 +0,0 @@ -""" -GUARDiA Data AI v2 — Gen6 -벡터DB·RAG v2·LoRA API·임베딩·시맨틱 검색·AI 파이프라인 관리 -""" -import os, httpx, uuid, json -from datetime import datetime -from typing import Any, Dict, List, Optional -from fastapi import APIRouter, HTTPException, Query -from pydantic import BaseModel - -router = APIRouter(prefix="/api/data-ai", tags=["Data AI v2"]) - -_OPEN = os.environ.get("GUARDIA_NETWORK_MODE") == "open" -OLLAMA = "http://localhost:11434" - -_vector_store: Dict[str, Dict] = {} # collection → {id → {vector, metadata}} -_collections: Dict[str, Dict] = {} -_lora_jobs: Dict[str, Dict] = {} -_pipelines: Dict[str, Dict] = {} -_embeddings_cache: Dict[str, List[float]] = {} - -class CollectionCreate(BaseModel): - name: str; dimension: int = 768; metric: str = "cosine" - description: str = "" - -class VectorInsert(BaseModel): - collection: str; id: Optional[str] = None - text: str; metadata: Dict[str, Any] = {} - -class VectorSearch(BaseModel): - collection: str; query: str; top_k: int = 5 - filter: Dict[str, Any] = {} - -class RAGQuery(BaseModel): - query: str; collection: str = "guardia-kb" - top_k: int = 3; model: str = "llama3" - include_sources: bool = True - -class LoRAJobCreate(BaseModel): - base_model: str = "llama3"; dataset_path: str - epochs: int = 3; learning_rate: float = 0.0001 - description: str = "" - -class PipelineCreate(BaseModel): - name: str; steps: List[Dict[str, Any]]; trigger: str = "manual" - -class EmbeddingRequest(BaseModel): - texts: List[str]; model: str = "nomic-embed-text" - -# ── 컬렉션 관리 ────────────────────────────────────────────────────────── -@router.post("/collections") -async def create_collection(col: CollectionCreate): - _collections[col.name] = {**col.model_dump(), "created_at": datetime.utcnow().isoformat(), - "doc_count": 0} - _vector_store[col.name] = {} - return _collections[col.name] - -@router.get("/collections") -async def list_collections(): - cols = list(_collections.values()) or [ - {"name": "guardia-kb", "dimension": 768, "doc_count": 142, "metric": "cosine"}, - {"name": "sr-history", "dimension": 768, "doc_count": 1024, "metric": "cosine"}, - ] - return {"collections": cols, "total": len(cols)} - -@router.get("/collections/{name}") -async def get_collection(name: str): - col = _collections.get(name, {"name": name, "dimension": 768, "doc_count": 0}) - return col - -@router.delete("/collections/{name}") -async def delete_collection(name: str): - _collections.pop(name, None); _vector_store.pop(name, None) - return {"deleted": name} - -# ── 벡터 삽입 / 검색 ────────────────────────────────────────────────────── -@router.post("/vectors/insert") -async def insert_vector(req: VectorInsert): - vid = req.id or str(uuid.uuid4()) - if req.collection not in _vector_store: - _vector_store[req.collection] = {} - # 임베딩 생성 (Ollama nomic-embed-text) - embedding = await _get_embedding(req.text) - _vector_store[req.collection][vid] = { - "id": vid, "text": req.text, "vector": embedding[:5] + ["..."], - "metadata": req.metadata, "inserted_at": datetime.utcnow().isoformat() - } - if req.collection in _collections: - _collections[req.collection]["doc_count"] += 1 - return {"id": vid, "collection": req.collection, "inserted": True} - -@router.post("/vectors/batch-insert") -async def batch_insert(collection: str, items: List[Dict[str, Any]]): - results = [] - for item in items[:100]: # max 100 per batch - vid = str(uuid.uuid4()) - results.append({"id": vid, "status": "inserted"}) - return {"collection": collection, "inserted": len(results), "results": results} - -@router.post("/vectors/search") -async def vector_search(req: VectorSearch): - """시맨틱 벡터 검색.""" - store = _vector_store.get(req.collection, {}) - results = list(store.values())[:req.top_k] - return { - "query": req.query, "collection": req.collection, - "results": [{"id": r["id"], "text": r["text"][:200], - "score": round(0.95 - i * 0.05, 3), "metadata": r["metadata"]} - for i, r in enumerate(results)], - "total_results": len(results), - } - -@router.delete("/vectors/{collection}/{vid}") -async def delete_vector(collection: str, vid: str): - store = _vector_store.get(collection, {}) - store.pop(vid, None); return {"deleted": vid, "collection": collection} - -# ── RAG v2 ──────────────────────────────────────────────────────────────── -@router.post("/rag/query") -async def rag_query(req: RAGQuery): - """RAG v2 — 벡터 검색 → LLM 답변 생성.""" - # 1) 벡터 검색 - search_result = await vector_search(VectorSearch( - collection=req.collection, query=req.query, top_k=req.top_k)) - sources = search_result.get("results", []) - - # 2) 컨텍스트 조합 - context = "\n".join([f"[{i+1}] {s['text'][:300]}" for i, s in enumerate(sources)]) - prompt = (f"다음 문서를 참고하여 질문에 답하라.\n\n문서:\n{context}\n\n질문: {req.query}\n\n답변:") - - # 3) LLM 호출 - answer = await _call_llm(req.model, prompt) - return { - "query": req.query, "answer": answer, "model": req.model, - "sources": sources if req.include_sources else [], - "collection": req.collection, "ts": datetime.utcnow().isoformat(), - } - -@router.post("/rag/index") -async def index_documents(collection: str, documents: List[str]): - for doc in documents[:50]: - vid = str(uuid.uuid4()) - if collection not in _vector_store: _vector_store[collection] = {} - _vector_store[collection][vid] = {"id": vid, "text": doc[:500], - "inserted_at": datetime.utcnow().isoformat()} - return {"collection": collection, "indexed": len(documents), "ts": datetime.utcnow().isoformat()} - -@router.get("/rag/collections") -async def rag_collections(): - return {"collections": [ - {"name": "guardia-kb", "docs": 142, "description": "GUARDiA 기술 문서 KB"}, - {"name": "sr-history", "docs": 1024, "description": "SR 처리 이력"}, - {"name": "runbooks", "docs": 56, "description": "운영 런북"}, - ]} - -# ── LoRA 파인튜닝 API ───────────────────────────────────────────────────── -@router.post("/lora/jobs") -async def create_lora_job(job: LoRAJobCreate): - jid = f"LORA-{uuid.uuid4().hex[:8].upper()}" - _lora_jobs[jid] = {**job.model_dump(), "id": jid, "status": "queued", - "progress": 0, "created_at": datetime.utcnow().isoformat()} - return _lora_jobs[jid] - -@router.get("/lora/jobs") -async def list_lora_jobs(): return {"jobs": list(_lora_jobs.values()), "total": len(_lora_jobs)} - -@router.get("/lora/jobs/{jid}") -async def get_lora_job(jid: str): - j = _lora_jobs.get(jid) - if not j: raise HTTPException(404) - return j - -@router.post("/lora/jobs/{jid}/start") -async def start_lora(jid: str): - j = _lora_jobs.get(jid) - if not j: raise HTTPException(404) - j["status"] = "training"; j["started_at"] = datetime.utcnow().isoformat() - return j - -@router.post("/lora/jobs/{jid}/cancel") -async def cancel_lora(jid: str): - j = _lora_jobs.get(jid) - if not j: raise HTTPException(404) - j["status"] = "cancelled"; return j - -@router.get("/lora/models") -async def list_lora_models(): - return {"models": [ - {"id": "guardia-lora-v1", "base": "llama3", "trained_on": "sr-history", - "accuracy": 0.89, "deployed": True}, - ]} - -# ── 임베딩 ──────────────────────────────────────────────────────────────── -@router.post("/embeddings") -async def create_embeddings(req: EmbeddingRequest): - results = [] - for text in req.texts[:50]: - emb = await _get_embedding(text) - results.append({"text": text[:100], "embedding": emb[:5] + [0.0] * (len(emb) - 5), - "dimension": len(emb)}) - return {"model": req.model, "embeddings": results, "count": len(results)} - -@router.get("/embeddings/models") -async def embedding_models(): - return {"models": [ - {"name": "nomic-embed-text", "dimension": 768, "available": True, "recommended": True}, - {"name": "mxbai-embed-large", "dimension": 1024, "available": False}, - ]} - -# ── AI 파이프라인 ───────────────────────────────────────────────────────── -@router.post("/pipelines") -async def create_pipeline(pipe: PipelineCreate): - pid = f"PIPE-{uuid.uuid4().hex[:8].upper()}" - _pipelines[pid] = {**pipe.model_dump(), "id": pid, "status": "ready", - "created_at": datetime.utcnow().isoformat()} - return _pipelines[pid] - -@router.get("/pipelines") -async def list_pipelines(): return {"pipelines": list(_pipelines.values())} - -@router.post("/pipelines/{pid}/run") -async def run_pipeline(pid: str, inputs: Dict[str, Any] = {}): - pipe = _pipelines.get(pid) - if not pipe: raise HTTPException(404) - run_id = str(uuid.uuid4()) - return {"run_id": run_id, "pipeline": pid, "inputs": inputs, - "status": "completed", "output": {"processed": True}, - "ts": datetime.utcnow().isoformat()} - -# ── 헬퍼 ────────────────────────────────────────────────────────────────── -async def _get_embedding(text: str) -> List[float]: - cached = _embeddings_cache.get(text[:100]) - if cached: return cached - try: - async with httpx.AsyncClient(timeout=30.0) as c: - r = await c.post(f"{OLLAMA}/api/embeddings", - json={"model": "nomic-embed-text", "prompt": text}) - if r.status_code == 200: - emb = r.json().get("embedding", [0.0] * 768) - _embeddings_cache[text[:100]] = emb - return emb - except Exception: - pass - import random - return [round(random.uniform(-1, 1), 4) for _ in range(768)] - -async def _call_llm(model: str, prompt: str) -> str: - try: - async with httpx.AsyncClient(timeout=60.0) as c: - r = await c.post(f"{OLLAMA}/api/generate", - json={"model": model, "prompt": prompt, "stream": False}) - if r.status_code == 200: return r.json().get("response", "") - except Exception: - pass - return f"[Ollama 불가] 쿼리: {prompt[:100]}" - -@router.get("/data-ai/health") -async def health(): - return {"status": "healthy", "collections": len(_collections), - "vectors_total": sum(len(v) for v in _vector_store.values()), - "lora_jobs": len(_lora_jobs), "pipelines": len(_pipelines)} diff --git a/routers/infra_native.py b/routers/infra_native.py deleted file mode 100644 index 1cbbb84..0000000 --- a/routers/infra_native.py +++ /dev/null @@ -1,245 +0,0 @@ -""" -GUARDiA 클라우드 네이티브 인프라 — Gen6 -eBPF 계측·Wasm 엣지·서비스 메시·이벤트 소싱·시크릿 관리·멀티런타임 -""" -import uuid -from datetime import datetime -from typing import Any, Dict, List, Optional -from fastapi import APIRouter, HTTPException, Query -from pydantic import BaseModel - -router = APIRouter(prefix="/api/infra", tags=["Cloud Native Infra"]) - -_ebpf_probes: Dict[str, Dict] = {} -_wasm_modules: Dict[str, Dict] = {} -_mesh_services: Dict[str, Dict] = {} -_events: List[Dict] = [] -_secrets: Dict[str, Dict] = {} -_runtimes: Dict[str, Dict] = {} - -class EBPFProbe(BaseModel): - name: str; program_type: str = "kprobe" # kprobe|tracepoint|xdp|tc - target: str; filter_expr: str = ""; owner: str = "platform" - -class WasmModule(BaseModel): - name: str; wasm_binary_url: str = "" - runtime: str = "wasmtime"; memory_mb: int = 64 - env: Dict[str, str] = {} - -class MeshService(BaseModel): - service: str; version: str = "v1" - protocol: str = "http2"; mtls: bool = True - circuit_breaker: bool = True; retries: int = 3 - -class EventCreate(BaseModel): - aggregate_id: str; aggregate_type: str - event_type: str; payload: Dict[str, Any] = {} - correlation_id: Optional[str] = None - -class SecretCreate(BaseModel): - name: str; value: str; engine: str = "vault" # vault|k8s|env - rotate_days: int = 90; owner: str = "" - -class RuntimeCreate(BaseModel): - name: str; runtime_type: str = "wasmtime" # wasmtime|spin|containerd|gvisor - config: Dict[str, Any] = {} - -# ── eBPF 계측 ───────────────────────────────────────────────────────────── -@router.post("/ebpf/probes") -async def create_ebpf_probe(probe: EBPFProbe): - pid = f"EBPF-{uuid.uuid4().hex[:8].upper()}" - _ebpf_probes[pid] = {**probe.model_dump(), "id": pid, "status": "attached", - "created_at": datetime.utcnow().isoformat(), "events_captured": 0} - return _ebpf_probes[pid] - -@router.get("/ebpf/probes") -async def list_ebpf_probes(): - probes = list(_ebpf_probes.values()) or [ - {"id": "EBPF-SYS001", "name": "syscall_monitor", "type": "kprobe", "status": "attached"}, - {"id": "EBPF-NET001", "name": "network_flow", "type": "xdp", "status": "attached"}, - ] - return {"probes": probes, "total": len(probes)} - -@router.get("/ebpf/probes/{pid}/metrics") -async def ebpf_probe_metrics(pid: str): - return {"probe_id": pid, "events_per_sec": 1240, "latency_p99_us": 45, - "cpu_overhead_pct": 0.3, "ts": datetime.utcnow().isoformat()} - -@router.delete("/ebpf/probes/{pid}") -async def detach_ebpf_probe(pid: str): - _ebpf_probes.pop(pid, None); return {"detached": pid} - -@router.get("/ebpf/trace") -async def live_trace(program: str = "syscall", duration_sec: int = 5): - return {"program": program, "duration_sec": duration_sec, - "trace": [ - {"ts": datetime.utcnow().isoformat(), "pid": 1234, "comm": "guardia-api", "event": "tcp_connect", "latency_ns": 4500}, - {"ts": datetime.utcnow().isoformat(), "pid": 1234, "comm": "guardia-api", "event": "sys_read", "latency_ns": 120}, - ]} - -@router.get("/ebpf/topology") -async def network_topology(): - return {"nodes": [ - {"id": "guardia-itsm", "type": "service", "ip": "10.0.1.10"}, - {"id": "guardia-manager", "type": "service", "ip": "10.0.1.11"}, - {"id": "postgres", "type": "database", "ip": "10.0.1.20"}, - ], "edges": [ - {"from": "guardia-itsm", "to": "postgres", "protocol": "tcp", "port": 5432}, - {"from": "guardia-manager", "to": "guardia-itsm", "protocol": "tcp", "port": 8001}, - ], "captured_by": "eBPF XDP"} - -# ── Wasm 엣지 모듈 ─────────────────────────────────────────────────────── -@router.post("/wasm/modules") -async def deploy_wasm(module: WasmModule): - mid = f"WASM-{uuid.uuid4().hex[:8].upper()}" - _wasm_modules[mid] = {**module.model_dump(), "id": mid, "status": "running", - "deployed_at": datetime.utcnow().isoformat()} - return _wasm_modules[mid] - -@router.get("/wasm/modules") -async def list_wasm(): - modules = list(_wasm_modules.values()) or [ - {"id": "WASM-EDGE01", "name": "request-validator", "runtime": "wasmtime", "status": "running"}, - ] - return {"modules": modules, "total": len(modules)} - -@router.get("/wasm/modules/{mid}/logs") -async def wasm_logs(mid: str, lines: int = 50): - return {"module_id": mid, "logs": [ - f"[2026-06-06T00:00:00Z] Module {mid} started", - f"[2026-06-06T00:00:01Z] Processed 1240 requests", - ][-lines:]} - -@router.post("/wasm/modules/{mid}/invoke") -async def invoke_wasm(mid: str, input: Dict[str, Any] = {}): - m = _wasm_modules.get(mid) - if not m: raise HTTPException(404) - return {"module_id": mid, "input": input, "output": {"result": "ok", "processed": True}, - "exec_time_ms": 1.2, "ts": datetime.utcnow().isoformat()} - -# ── 서비스 메시 ──────────────────────────────────────────────────────────── -@router.post("/mesh/services") -async def register_mesh_service(svc: MeshService): - sid = f"MESH-{uuid.uuid4().hex[:8].upper()}" - _mesh_services[sid] = {**svc.model_dump(), "id": sid, "status": "enrolled", - "enrolled_at": datetime.utcnow().isoformat()} - return _mesh_services[sid] - -@router.get("/mesh/services") -async def list_mesh_services(): - svcs = list(_mesh_services.values()) or [ - {"service": "guardia-itsm", "mtls": True, "status": "enrolled"}, - {"service": "guardia-manager", "mtls": True, "status": "enrolled"}, - ] - return {"services": svcs, "total": len(svcs)} - -@router.get("/mesh/traffic") -async def mesh_traffic(): - return {"services": [ - {"from": "guardia-manager", "to": "guardia-itsm", "rps": 142, "error_rate": 0.1, "p99_ms": 45}, - {"from": "guardia-itsm", "to": "postgres", "rps": 520, "error_rate": 0.0, "p99_ms": 12}, - ]} - -@router.get("/mesh/policies") -async def mesh_policies(): - return {"policies": [ - {"type": "circuit_breaker", "service": "guardia-itsm", "threshold": 50, "window_sec": 10}, - {"type": "retry", "service": "guardia-manager", "max_attempts": 3, "backoff_ms": 100}, - ]} - -@router.post("/mesh/policies") -async def create_mesh_policy(service: str, policy_type: str, rules: Dict[str, Any] = {}): - return {"id": f"POL-{uuid.uuid4().hex[:8].upper()}", "service": service, - "type": policy_type, "rules": rules, "applied": True, - "ts": datetime.utcnow().isoformat()} - -# ── 이벤트 소싱 ──────────────────────────────────────────────────────────── -@router.post("/events/publish") -async def publish_event(event: EventCreate): - eid = f"EVT-{uuid.uuid4().hex[:8].upper()}" - record = {**event.model_dump(), "id": eid, "sequence": len(_events) + 1, - "published_at": datetime.utcnow().isoformat()} - _events.append(record) - return record - -@router.get("/events/stream") -async def get_event_stream(aggregate_id: Optional[str] = None, - event_type: Optional[str] = None, limit: int = 100): - evts = _events - if aggregate_id: evts = [e for e in evts if e["aggregate_id"] == aggregate_id] - if event_type: evts = [e for e in evts if e["event_type"] == event_type] - return {"events": evts[-limit:], "total": len(evts)} - -@router.get("/events/replay/{aggregate_id}") -async def replay_events(aggregate_id: str, from_sequence: int = 0): - evts = [e for e in _events if e["aggregate_id"] == aggregate_id - and e.get("sequence", 0) >= from_sequence] - return {"aggregate_id": aggregate_id, "events": evts, "replayed": len(evts)} - -@router.get("/events/projections") -async def list_projections(): - return {"projections": [ - {"name": "sr-read-model", "last_event": len(_events), "status": "up-to-date"}, - {"name": "server-state", "last_event": len(_events), "status": "up-to-date"}, - ]} - -# ── 시크릿 관리 ──────────────────────────────────────────────────────────── -@router.post("/secrets") -async def create_secret(secret: SecretCreate): - sid = f"SEC-{uuid.uuid4().hex[:8].upper()}" - _secrets[sid] = {"id": sid, "name": secret.name, "engine": secret.engine, - "rotate_days": secret.rotate_days, "owner": secret.owner, - "value": "***ENCRYPTED***", - "created_at": datetime.utcnow().isoformat()} - return {k: v for k, v in _secrets[sid].items() if k != "value"} - -@router.get("/secrets") -async def list_secrets(): - return {"secrets": [{k: v for k, v in s.items() if k != "value"} - for s in _secrets.values()]} - -@router.post("/secrets/{name}/rotate") -async def rotate_secret(name: str): - return {"name": name, "rotated": True, "new_version": f"v{uuid.uuid4().hex[:4]}", - "ts": datetime.utcnow().isoformat()} - -@router.get("/secrets/{name}/audit") -async def secret_audit(name: str): - return {"name": name, "access_log": [ - {"user": "guardia-itsm", "action": "read", "ts": datetime.utcnow().isoformat()}, - ], "rotation_history": [{"version": "v1", "ts": datetime.utcnow().isoformat()}]} - -# ── 멀티 런타임 관리 ────────────────────────────────────────────────────── -@router.post("/runtimes") -async def create_runtime(rt: RuntimeCreate): - rid = f"RT-{uuid.uuid4().hex[:8].upper()}" - _runtimes[rid] = {**rt.model_dump(), "id": rid, "status": "ready", - "created_at": datetime.utcnow().isoformat()} - return _runtimes[rid] - -@router.get("/runtimes") -async def list_runtimes(): - rts = list(_runtimes.values()) or [ - {"id": "RT-WASM01", "name": "wasmtime-edge", "type": "wasmtime", "status": "ready"}, - {"id": "RT-CONT01", "name": "containerd-shim", "type": "containerd", "status": "ready"}, - ] - return {"runtimes": rts, "total": len(rts)} - -@router.get("/runtimes/{rid}/stats") -async def runtime_stats(rid: str): - return {"runtime_id": rid, "cpu_cores": 4, "memory_used_mb": 512, - "modules_running": len(_wasm_modules), "uptime_sec": 86400, - "ts": datetime.utcnow().isoformat()} - -# ── 클라우드 네이티브 상태 ──────────────────────────────────────────────── -@router.get("/native/health") -async def native_health(): - return {"status": "healthy", "ebpf_probes": len(_ebpf_probes), - "wasm_modules": len(_wasm_modules), "mesh_services": len(_mesh_services), - "events_stored": len(_events), "secrets": len(_secrets), "runtimes": len(_runtimes)} - -@router.get("/native/overview") -async def native_overview(): - return {"gen": 6, "capabilities": ["eBPF", "Wasm Edge", "Service Mesh", "Event Sourcing", - "Secret Manager", "Multi-Runtime"], - "maturity": "production", "last_updated": datetime.utcnow().isoformat()} diff --git a/routers/inventory.py b/routers/inventory.py deleted file mode 100644 index 1723ce9..0000000 --- a/routers/inventory.py +++ /dev/null @@ -1,171 +0,0 @@ -""" -부품 재고 API (모바일 기능 #62). - - GET /api/inventory/parts — 부품 목록 (tenant 필터) - GET /api/inventory/parts/{id} — 부품 상세 - POST /api/inventory/parts — 부품 등록 - POST /api/inventory/parts/{id}/request — 부품 요청 → SR 자동 생성 -""" -from __future__ import annotations - -from datetime import datetime -from typing import List, Optional -from uuid import uuid4 - -from fastapi import APIRouter, Depends, HTTPException -from pydantic import BaseModel, ConfigDict -from sqlalchemy import select -from sqlalchemy.ext.asyncio import AsyncSession - -from core.auth import get_current_user -from database import get_db -from models import Institution, InventoryPart, SRRequest, SRStatus, SRType, User - -router = APIRouter(prefix="/api/inventory", tags=["Inventory"]) - - -def _tenant_of(user: User) -> str: - return user.inst_code or f"user:{user.username}" - - -class PartCreate(BaseModel): - name: str - model: Optional[str] = None - quantity: int = 0 - min_quantity: int = 1 - location: Optional[str] = None - - -class PartOut(BaseModel): - model_config = ConfigDict(from_attributes=True) - - id: int - name: str - model: Optional[str] - quantity: int - min_quantity: int - location: Optional[str] - low_stock: bool = False - - -class PartRequest(BaseModel): - quantity: int = 1 - reason: Optional[str] = None - target_server: Optional[str] = None - - -def _to_out(p: InventoryPart) -> dict: - return { - "id": p.id, - "name": p.name, - "model": p.model, - "quantity": p.quantity, - "min_quantity": p.min_quantity, - "location": p.location, - "low_stock": (p.quantity or 0) <= (p.min_quantity or 0), - } - - -@router.get("/parts", response_model=List[PartOut]) -async def list_parts( - low_stock_only: bool = False, - db: AsyncSession = Depends(get_db), - current_user: User = Depends(get_current_user), -): - """내 테넌트의 부품 목록.""" - q = select(InventoryPart).where( - InventoryPart.tenant_id == _tenant_of(current_user) - ).order_by(InventoryPart.name) - rows = (await db.execute(q)).scalars().all() - out = [_to_out(p) for p in rows] - if low_stock_only: - out = [p for p in out if p["low_stock"]] - return out - - -@router.post("/parts", response_model=PartOut, status_code=201) -async def create_part( - payload: PartCreate, - db: AsyncSession = Depends(get_db), - current_user: User = Depends(get_current_user), -): - part = InventoryPart( - tenant_id=_tenant_of(current_user), - name=payload.name, - model=payload.model, - quantity=payload.quantity, - min_quantity=payload.min_quantity, - location=payload.location, - ) - db.add(part) - await db.commit() - await db.refresh(part) - return _to_out(part) - - -async def _get_owned_part(part_id: int, db: AsyncSession, user: User) -> InventoryPart: - part = await db.get(InventoryPart, part_id) - if not part or part.tenant_id != _tenant_of(user): - raise HTTPException(404, "부품을 찾을 수 없습니다.") - return part - - -@router.get("/parts/{part_id}", response_model=PartOut) -async def get_part( - part_id: int, - db: AsyncSession = Depends(get_db), - current_user: User = Depends(get_current_user), -): - part = await _get_owned_part(part_id, db, current_user) - return _to_out(part) - - -@router.post("/parts/{part_id}/request", status_code=201) -async def request_part( - part_id: int, - payload: PartRequest, - db: AsyncSession = Depends(get_db), - current_user: User = Depends(get_current_user), -): - """부품 요청 → SR 자동 생성.""" - part = await _get_owned_part(part_id, db, current_user) - if payload.quantity < 1: - raise HTTPException(422, "요청 수량은 1 이상이어야 합니다.") - - # 소속 기관 id 매핑 (있으면) - inst_id = None - if current_user.inst_code: - inst = (await db.execute( - select(Institution).where(Institution.inst_code == current_user.inst_code) - )).scalars().first() - if inst: - inst_id = inst.id - - sr_id = f"SR-{datetime.now().strftime('%Y%m%d')}-{str(uuid4())[:6].upper()}" - desc = ( - f"부품 요청\n" - f"- 부품명: {part.name}\n" - f"- 모델: {part.model or '-'}\n" - f"- 요청수량: {payload.quantity}\n" - f"- 보관위치: {part.location or '-'}\n" - f"- 사유: {payload.reason or '-'}" - ) - sr = SRRequest( - sr_id=sr_id, - inst_id=inst_id, - sr_type=SRType.OTHER, - title=f"[부품요청] {part.name} x{payload.quantity}", - description=desc, - status=SRStatus.RECEIVED, - requested_by=current_user.username, - target_server=payload.target_server, - ) - db.add(sr) - await db.commit() - return { - "sr_id": sr_id, - "part_id": part.id, - "part_name": part.name, - "requested_quantity": payload.quantity, - "message": "부품 요청 SR이 생성되었습니다.", - } diff --git a/routers/mcp_agents.py b/routers/mcp_agents.py deleted file mode 100644 index 3c1fdc6..0000000 --- a/routers/mcp_agents.py +++ /dev/null @@ -1,233 +0,0 @@ -""" -GUARDiA MCP (Model Context Protocol) 에이전트 메시 -MCP 서버 관리, 에이전트 메시 네트워킹, tool-calling 오케스트레이션 -Gen6 — 온프레미스 Ollama 기반, 개방망 외부 LLM 허용 -""" -import os, httpx, json, uuid -from datetime import datetime -from typing import Any, Dict, List, Optional -from fastapi import APIRouter, HTTPException, WebSocket, WebSocketDisconnect, Query -from pydantic import BaseModel - -router = APIRouter(prefix="/api/mcp", tags=["MCP Agent Mesh"]) - -_OPEN = os.environ.get("GUARDIA_NETWORK_MODE") == "open" -OLLAMA = "http://localhost:11434" - -# ── 인메모리 레지스트리 ────────────────────────────────────────────────── -_mcp_servers: Dict[str, Dict] = {} -_agent_nodes: Dict[str, Dict] = {} -_tool_registry: Dict[str, Dict] = {} -_sessions: Dict[str, Dict] = {} -_ws_clients: Dict[str, WebSocket] = {} - -# ── 모델 ────────────────────────────────────────────────────────────────── -class McpServerCreate(BaseModel): - name: str; endpoint: str; protocol: str = "mcp/1.0" - tools: List[str] = []; auth_token: Optional[str] = None - -class AgentNode(BaseModel): - agent_id: str; role: str; model: str = "llama3" - capabilities: List[str] = []; upstream: Optional[str] = None - -class ToolCall(BaseModel): - tool_name: str; params: Dict[str, Any] = {} - caller_agent: str = "orchestrator"; session_id: Optional[str] = None - -class MeshMessage(BaseModel): - from_agent: str; to_agent: str - content: str; msg_type: str = "task" # task|result|broadcast|heartbeat - -class OrchestrationPlan(BaseModel): - goal: str; agents: List[str]; steps: List[Dict[str, Any]] - parallel: bool = False - -class PromptRequest(BaseModel): - prompt: str; model: str = "llama3" - tools: List[str] = []; context: Optional[str] = None - -# ── MCP 서버 관리 ────────────────────────────────────────────────────────── -@router.post("/servers") -async def register_server(s: McpServerCreate): - sid = f"MCP-{uuid.uuid4().hex[:8].upper()}" - _mcp_servers[sid] = {**s.model_dump(), "id": sid, "status": "active", - "registered_at": datetime.utcnow().isoformat()} - return _mcp_servers[sid] - -@router.get("/servers") -async def list_servers(): return {"servers": list(_mcp_servers.values()), "count": len(_mcp_servers)} - -@router.get("/servers/{sid}") -async def get_server(sid: str): - s = _mcp_servers.get(sid) - if not s: raise HTTPException(404) - return s - -@router.delete("/servers/{sid}") -async def remove_server(sid: str): - _mcp_servers.pop(sid, None); return {"removed": sid} - -@router.post("/servers/{sid}/ping") -async def ping_server(sid: str): - s = _mcp_servers.get(sid) - if not s: raise HTTPException(404) - return {"server_id": sid, "ping": "ok", "latency_ms": 12, "ts": datetime.utcnow().isoformat()} - -# ── 에이전트 노드 ───────────────────────────────────────────────────────── -@router.post("/agents") -async def register_agent(node: AgentNode): - _agent_nodes[node.agent_id] = {**node.model_dump(), "status": "idle", - "joined_at": datetime.utcnow().isoformat(), "tasks_done": 0} - return _agent_nodes[node.agent_id] - -@router.get("/agents") -async def list_agents(): return {"agents": list(_agent_nodes.values()), "count": len(_agent_nodes)} - -@router.get("/agents/{aid}") -async def get_agent(aid: str): - a = _agent_nodes.get(aid) - if not a: raise HTTPException(404) - return a - -@router.patch("/agents/{aid}/status") -async def update_agent_status(aid: str, status: str = Query(...)): - if aid not in _agent_nodes: raise HTTPException(404) - _agent_nodes[aid]["status"] = status - return {"agent_id": aid, "status": status} - -@router.get("/agents/{aid}/history") -async def agent_history(aid: str): - tasks = [s for s in _sessions.values() if aid in s.get("agents", [])] - return {"agent_id": aid, "sessions": tasks[-20:]} - -# ── Tool 레지스트리 ──────────────────────────────────────────────────────── -@router.post("/tools") -async def register_tool(name: str, description: str, params_schema: Dict = {}): - _tool_registry[name] = {"name": name, "description": description, - "params_schema": params_schema, "calls": 0, - "registered_at": datetime.utcnow().isoformat()} - return _tool_registry[name] - -@router.get("/tools") -async def list_tools(): return {"tools": list(_tool_registry.values()), "count": len(_tool_registry)} - -@router.post("/tools/call") -async def call_tool(req: ToolCall): - tool = _tool_registry.get(req.tool_name) - if not tool: raise HTTPException(404, f"Tool not found: {req.tool_name}") - tool["calls"] += 1 - # 실제 tool 실행 — Ollama 기반 시뮬레이션 - call_id = str(uuid.uuid4()) - result = { - "call_id": call_id, "tool": req.tool_name, - "params": req.params, "caller": req.caller_agent, - "result": {"status": "success", "output": f"Tool {req.tool_name} executed with {req.params}"}, - "executed_at": datetime.utcnow().isoformat(), - } - return result - -# ── 에이전트 메시 통신 ──────────────────────────────────────────────────── -@router.post("/mesh/send") -async def send_message(msg: MeshMessage): - msg_id = str(uuid.uuid4()) - record = {**msg.model_dump(), "id": msg_id, "ts": datetime.utcnow().isoformat(), "delivered": False} - # WebSocket으로 to_agent에게 전달 - ws = _ws_clients.get(msg.to_agent) - if ws: - try: - await ws.send_json(record) - record["delivered"] = True - except Exception: - _ws_clients.pop(msg.to_agent, None) - return record - -@router.post("/mesh/broadcast") -async def broadcast_message(content: str, from_agent: str = "orchestrator"): - results = [] - for aid, ws in list(_ws_clients.items()): - try: - await ws.send_json({"type": "broadcast", "from": from_agent, "content": content, - "ts": datetime.utcnow().isoformat()}) - results.append({"agent": aid, "delivered": True}) - except Exception: - _ws_clients.pop(aid, None) - return {"broadcast": True, "delivered": len(results), "results": results} - -@router.websocket("/ws/{agent_id}") -async def agent_ws(ws: WebSocket, agent_id: str): - await ws.accept() - _ws_clients[agent_id] = ws - if agent_id in _agent_nodes: - _agent_nodes[agent_id]["status"] = "connected" - try: - await ws.send_json({"type": "connected", "agent_id": agent_id}) - while True: - data = json.loads(await ws.receive_text()) - if data.get("type") == "heartbeat": - await ws.send_json({"type": "heartbeat_ack", "ts": datetime.utcnow().isoformat()}) - except WebSocketDisconnect: - pass - finally: - _ws_clients.pop(agent_id, None) - if agent_id in _agent_nodes: - _agent_nodes[agent_id]["status"] = "idle" - -# ── 오케스트레이션 세션 ─────────────────────────────────────────────────── -@router.post("/orchestrate") -async def orchestrate(plan: OrchestrationPlan): - session_id = f"SES-{uuid.uuid4().hex[:8].upper()}" - session = { - "session_id": session_id, "goal": plan.goal, - "agents": plan.agents, "steps": plan.steps, - "status": "running", "parallel": plan.parallel, - "results": [], "started_at": datetime.utcnow().isoformat(), - } - _sessions[session_id] = session - # 간단한 순차/병렬 시뮬레이션 - for i, step in enumerate(plan.steps): - session["results"].append({ - "step": i + 1, "action": step.get("action", ""), "agent": step.get("agent", ""), - "status": "completed", "ts": datetime.utcnow().isoformat(), - }) - session["status"] = "completed" - session["completed_at"] = datetime.utcnow().isoformat() - return session - -@router.get("/sessions") -async def list_sessions(): return {"sessions": list(_sessions.values())[-20:], "total": len(_sessions)} - -@router.get("/sessions/{sid}") -async def get_session(sid: str): - s = _sessions.get(sid) - if not s: raise HTTPException(404) - return s - -# ── LLM 프롬프트 (MCP 스타일 tool-calling) ─────────────────────────────── -@router.post("/prompt") -async def mcp_prompt(req: PromptRequest): - """MCP tool-calling 스타일 프롬프트 — Ollama 온프레미스 (개방망: 외부 가능).""" - tool_hint = f"\nAvailable tools: {req.tools}" if req.tools else "" - ctx_hint = f"\nContext: {req.context}" if req.context else "" - prompt = f"{req.prompt}{tool_hint}{ctx_hint}" - - async with httpx.AsyncClient(timeout=60.0) as c: - r = await c.post(f"{OLLAMA}/api/generate", - json={"model": req.model, "prompt": prompt, "stream": False}) - response = r.json().get("response", "") if r.status_code == 200 else "Ollama 불가" - return {"prompt": req.prompt, "response": response, "model": req.model, - "tools_used": req.tools, "ts": datetime.utcnow().isoformat()} - -# ── 메시 토폴로지 시각화 ─────────────────────────────────────────────────── -@router.get("/topology") -async def mesh_topology(): - nodes = [{"id": aid, **{k: v for k, v in a.items() if k != "agent_id"}} - for aid, a in _agent_nodes.items()] - edges = [{"from": a["upstream"], "to": aid} - for aid, a in _agent_nodes.items() if a.get("upstream")] - return {"nodes": nodes, "edges": edges, "servers": len(_mcp_servers), - "tools": len(_tool_registry), "active_sessions": sum(1 for s in _sessions.values() if s["status"] == "running")} - -@router.get("/health") -async def mcp_health(): - return {"status": "healthy", "servers": len(_mcp_servers), "agents": len(_agent_nodes), - "tools": len(_tool_registry), "sessions": len(_sessions), "open_network": _OPEN} diff --git a/routers/patches.py b/routers/patches.py deleted file mode 100644 index cdf9718..0000000 --- a/routers/patches.py +++ /dev/null @@ -1,123 +0,0 @@ -""" -CVE 패치 현황 API (모바일 기능 #82, #83). - -GET /api/patches/cve — CVE 목록 (severity 필터) -GET /api/patches/status — 서버별 패치 적용률 (IP 노출 금지) -GET /api/patches/pending — 미적용 패치 목록 -GET /api/patches/pii-types — PII 데이터 처리 유형 목록 -POST /api/patches/{cve_id}/apply — 패치 적용 SR 자동 생성 -""" -from __future__ import annotations - -import hashlib -from datetime import datetime, date -from typing import List, Optional - -from fastapi import APIRouter, Depends -from pydantic import BaseModel, ConfigDict -from sqlalchemy import select, func -from sqlalchemy.ext.asyncio import AsyncSession - -from core.auth import get_current_user -from database import get_db -from models import AuditLog, SRRequest, SRStatus, SRType, Priority, User - -router = APIRouter(prefix="/api/patches", tags=["Patches"]) - -_MOCK_CVE = [ - {"id": "CVE-2024-1001", "severity": "critical", "title": "OpenSSL 원격 코드 실행", "affected": "OpenSSL < 3.2.1", "cvss": 9.8, "patch_available": True, "patched_servers": 3, "total_servers": 10}, - {"id": "CVE-2024-1002", "severity": "high", "title": "Apache httpd 디렉토리 탐색", "affected": "Apache < 2.4.59", "cvss": 7.5, "patch_available": True, "patched_servers": 8, "total_servers": 10}, - {"id": "CVE-2024-1003", "severity": "high", "title": "Linux 커널 권한 상승", "affected": "kernel < 6.8.2", "cvss": 7.8, "patch_available": True, "patched_servers": 5, "total_servers": 10}, - {"id": "CVE-2024-1004", "severity": "medium", "title": "SSH 취약 암호화 허용", "affected": "OpenSSH < 9.7", "cvss": 5.3, "patch_available": True, "patched_servers": 9, "total_servers": 10}, - {"id": "CVE-2024-1005", "severity": "medium", "title": "Python urllib SSRF", "affected": "Python < 3.12.3", "cvss": 5.9, "patch_available": False, "patched_servers": 0, "total_servers": 10}, -] - -_MOCK_SERVERS = [ - {"name": "WEB-01", "role": "웹서버", "patch_rate": 85, "pending": 2}, - {"name": "WEB-02", "role": "웹서버", "patch_rate": 70, "pending": 4}, - {"name": "APP-01", "role": "앱서버", "patch_rate": 95, "pending": 1}, - {"name": "APP-02", "role": "앱서버", "patch_rate": 60, "pending": 5}, - {"name": "DB-01", "role": "DB서버", "patch_rate": 100, "pending": 0}, -] - -_PII_TYPES = [ - {"code": "PII_001", "name": "주민등록번호", "storage": "암호화 DB", "retention": "5년", "status": "compliant"}, - {"code": "PII_002", "name": "연락처", "storage": "암호화 DB", "retention": "3년", "status": "compliant"}, - {"code": "PII_003", "name": "이메일", "storage": "평문 로그", "retention": "미정", "status": "non_compliant"}, - {"code": "PII_004", "name": "IP 주소", "storage": "감사 로그", "retention": "1년", "status": "compliant"}, -] - - -class PatchApplyOut(BaseModel): - sr_id: int - message: str - - -@router.get("/cve") -async def list_cve(severity: Optional[str] = None): - data = _MOCK_CVE - if severity: - data = [c for c in data if c["severity"] == severity] - return {"total": len(data), "items": data} - - -@router.get("/status") -async def patch_status(): - total_rate = round(sum(s["patch_rate"] for s in _MOCK_SERVERS) / len(_MOCK_SERVERS), 1) - return {"overall_patch_rate": total_rate, "servers": _MOCK_SERVERS} - - -@router.get("/pending") -async def pending_patches(): - pending = [c for c in _MOCK_CVE if c["patched_servers"] < c["total_servers"]] - return {"total": len(pending), "items": pending} - - -@router.get("/pii-types") -async def pii_types(): - return {"items": _PII_TYPES} - - -@router.post("/{cve_id}/apply", response_model=PatchApplyOut, status_code=201) -async def apply_patch( - cve_id: str, - db: AsyncSession = Depends(get_db), - current_user: User = Depends(get_current_user), -): - """패치 적용 SR 자동 생성.""" - cve = next((c for c in _MOCK_CVE if c["id"] == cve_id), None) - title = f"[패치] {cve['title'] if cve else cve_id} 적용" - sr = SRRequest( - sr_type=SRType.OTHER, - title=title, - description=f"CVE ID: {cve_id}\n적용 대상: {cve['affected'] if cve else '전체 서버'}", - status=SRStatus.RECEIVED, - priority=Priority.HIGH, - requested_by=current_user.username, - ) - db.add(sr) - await db.flush() - - prev = await db.execute( - select(AuditLog).order_by(AuditLog.id.desc()).limit(1) - ) - prev_row = prev.scalar_one_or_none() - prev_hash = prev_row.log_hash if prev_row else "0" * 64 - - ts = datetime.now() - raw = f"{prev_hash}|{current_user.username}|PATCH_SR_CREATE|{title}|{ts.isoformat()}" - log_hash = hashlib.sha256(raw.encode()).hexdigest() - - audit = AuditLog( - entity_type="sr_request", - entity_id=str(sr.sr_id), - actor=current_user.username, - action="PATCH_SR_CREATE", - detail=f"CVE {cve_id} 패치 적용 SR 생성", - log_hash=log_hash, - prev_hash=prev_hash, - created_at=ts, - ) - db.add(audit) - await db.commit() - return PatchApplyOut(sr_id=sr.sr_id, message=f"SR #{sr.sr_id} 생성됨") diff --git a/routers/platform_eng.py b/routers/platform_eng.py deleted file mode 100644 index f35b668..0000000 --- a/routers/platform_eng.py +++ /dev/null @@ -1,202 +0,0 @@ -""" -GUARDiA 플랫폼 엔지니어링 (Platform Engineering) — Gen6 -IDP 고도화·골든 패스 템플릿·소프트웨어 카탈로그 v2·셀프서비스 인프라 -""" -import uuid -from datetime import datetime -from typing import Any, Dict, List, Optional -from fastapi import APIRouter, HTTPException, Query -from pydantic import BaseModel - -router = APIRouter(prefix="/api/platform", tags=["Platform Engineering"]) - -# ── 인메모리 스토어 ──────────────────────────────────────────────────────── -_catalog: Dict[str, Dict] = {} -_templates: Dict[str, Dict] = {} -_environments: Dict[str, Dict] = {} -_service_levels: Dict[str, Dict] = {} -_requests: Dict[str, Dict] = {} - -# ── 사전 로드 카탈로그 ──────────────────────────────────────────────────── -def _init(): - for svc in [ - {"id": "svc-itsm", "name": "GUARDiA ITSM", "type": "backend", "language": "python", "version": "2.1.0", "owner": "platform-team", "status": "production"}, - {"id": "svc-manager", "name": "GUARDiA Manager", "type": "frontend", "language": "typescript", "version": "1.5.0", "owner": "platform-team", "status": "production"}, - {"id": "svc-messenger", "name": "GUARDiA Messenger", "type": "mobile", "language": "typescript", "version": "1.0.0", "owner": "mobile-team", "status": "production"}, - {"id": "svc-web", "name": "zioinfo Homepage", "type": "fullstack", "language": "java+typescript", "version": "3.0.0", "owner": "web-team", "status": "production"}, - ]: - _catalog[svc["id"]] = svc -_init() - -# ── 모델 ────────────────────────────────────────────────────────────────── -class ServiceCreate(BaseModel): - name: str; type: str; language: str; owner: str - description: str = ""; tags: List[str] = [] - -class TemplateCreate(BaseModel): - name: str; type: str # fastapi|react|react-native|springboot|ansible - description: str = ""; variables: Dict[str, Any] = {} - -class EnvironmentCreate(BaseModel): - name: str; type: str = "dev" # dev|staging|prod|dr - services: List[str] = []; config: Dict[str, Any] = {} - -class SelfServiceRequest(BaseModel): - service: str; action: str # create|scale|deploy|rollback|restart - params: Dict[str, Any] = {}; requested_by: str = "developer" - -# ── 소프트웨어 카탈로그 ────────────────────────────────────────────────── -@router.get("/catalog") -async def list_catalog(type: Optional[str] = None, status: Optional[str] = None): - svcs = list(_catalog.values()) - if type: svcs = [s for s in svcs if s.get("type") == type] - if status: svcs = [s for s in svcs if s.get("status") == status] - return {"services": svcs, "total": len(svcs)} - -@router.post("/catalog") -async def add_service(svc: ServiceCreate): - sid = f"svc-{uuid.uuid4().hex[:8]}" - _catalog[sid] = {**svc.model_dump(), "id": sid, "status": "registered", - "version": "0.1.0", "created_at": datetime.utcnow().isoformat()} - return _catalog[sid] - -@router.get("/catalog/{sid}") -async def get_service(sid: str): - s = _catalog.get(sid) - if not s: raise HTTPException(404) - return s - -@router.get("/catalog/{sid}/dependencies") -async def service_dependencies(sid: str): - return {"service_id": sid, "depends_on": ["svc-itsm"], "depended_by": [], - "impact_level": "high" if sid == "svc-itsm" else "medium"} - -@router.get("/catalog/{sid}/docs") -async def service_docs(sid: str): - s = _catalog.get(sid) - if not s: raise HTTPException(404) - return {"service_id": sid, "readme": f"# {s['name']}\n\n운영 문서", - "api_docs": f"/api/{sid}/docs", "runbook": f"/api/kb/runbook/{sid}"} - -# ── 골든 패스 템플릿 ────────────────────────────────────────────────────── -@router.get("/templates") -async def list_templates(): - BUILTIN = [ - {"id": "tpl-fastapi", "name": "FastAPI 마이크로서비스", "type": "fastapi", - "features": ["JWT 인증", "SQLAlchemy", "Ollama AI", "CORS", "헬스체크"]}, - {"id": "tpl-react-ts", "name": "React TypeScript SPA", "type": "react", - "features": ["Tailwind CSS", "React Query", "Zustand", "Vite", "다국어"]}, - {"id": "tpl-rn-expo", "name": "React Native Expo", "type": "react-native", - "features": ["Expo SDK 51", "TypeScript", "Zustand", "WebSocket", "오프라인"]}, - {"id": "tpl-springboot", "name": "Spring Boot API", "type": "springboot", - "features": ["JPA", "보안", "Swagger", "Actuator", "GraalVM"]}, - {"id": "tpl-ansible", "name": "Ansible 플레이북", "type": "ansible", - "features": ["에이전트리스", "SSH", "멱등성", "태그", "롤백"]}, - {"id": "tpl-k8s", "name": "K8s 배포 구성", "type": "kubernetes", - "features": ["Deployment", "Service", "HPA", "ConfigMap", "Secret"]}, - ] - custom = list(_templates.values()) - return {"builtin": BUILTIN, "custom": custom, "total": len(BUILTIN) + len(custom)} - -@router.post("/templates") -async def create_template(t: TemplateCreate): - tid = f"tpl-{uuid.uuid4().hex[:8]}" - _templates[tid] = {**t.model_dump(), "id": tid, "created_at": datetime.utcnow().isoformat()} - return _templates[tid] - -@router.post("/templates/{tid}/scaffold") -async def scaffold_from_template(tid: str, project_name: str, variables: Dict[str, Any] = {}): - return { - "template_id": tid, "project_name": project_name, "variables": variables, - "scaffolded": True, "files_created": ["main.py", "models.py", "README.md", "Dockerfile", ".env.example"], - "next_steps": ["cd " + project_name, "pip install -r requirements.txt", "python main.py"], - "ts": datetime.utcnow().isoformat(), - } - -# ── 환경 관리 ──────────────────────────────────────────────────────────── -@router.get("/environments") -async def list_environments(): - envs = list(_environments.values()) or [ - {"id": "env-dev", "name": "개발", "type": "dev", "services": list(_catalog.keys())[:2], "health": "healthy"}, - {"id": "env-prod", "name": "운영", "type": "prod", "services": list(_catalog.keys()), "health": "healthy"}, - ] - return {"environments": envs} - -@router.post("/environments") -async def create_environment(env: EnvironmentCreate): - eid = f"env-{uuid.uuid4().hex[:8]}" - _environments[eid] = {**env.model_dump(), "id": eid, "health": "creating", - "created_at": datetime.utcnow().isoformat()} - return _environments[eid] - -@router.get("/environments/{eid}/diff") -async def env_diff(eid: str, compare_with: str = "env-prod"): - return {"env1": eid, "env2": compare_with, "differences": [ - {"type": "config", "key": "DB_URL", "env1": "localhost", "env2": "prod-db:5432"}, - {"type": "version", "service": "guardia-itsm", "env1": "2.0.0", "env2": "2.1.0"}, - ]} - -@router.post("/environments/{eid}/promote") -async def promote_environment(eid: str, target: str = Query(...)): - return {"from": eid, "to": target, "promoted": True, "ts": datetime.utcnow().isoformat()} - -# ── 셀프서비스 인프라 ──────────────────────────────────────────────────── -@router.post("/self-service") -async def self_service(req: SelfServiceRequest): - req_id = f"REQ-{uuid.uuid4().hex[:8].upper()}" - result = { - "request_id": req_id, "service": req.service, "action": req.action, - "params": req.params, "requested_by": req.requested_by, - "status": "approved", "auto_approved": True, - "ts": datetime.utcnow().isoformat(), - } - _requests[req_id] = result - return result - -@router.get("/self-service") -async def list_requests(status: Optional[str] = None): - reqs = list(_requests.values()) - if status: reqs = [r for r in reqs if r.get("status") == status] - return {"requests": reqs[-50:], "total": len(reqs)} - -# ── 플랫폼 메트릭 ───────────────────────────────────────────────────────── -@router.get("/metrics") -async def platform_metrics(): - return { - "services": {"total": len(_catalog), "healthy": len(_catalog), "degraded": 0}, - "deployments_today": 8, "avg_deploy_time_min": 4.2, - "golden_path_adoption": 87.5, "self_service_requests_week": 34, - "developer_satisfaction": 4.7, - } - -@router.get("/metrics/adoption") -async def golden_path_adoption(): - return { - "fastapi_template": {"used": 12, "adoption_rate": 92.3}, - "react_ts_template": {"used": 8, "adoption_rate": 88.1}, - "ansible_template": {"used": 5, "adoption_rate": 76.4}, - "overall": 87.5, "target": 95.0, - } - -# ── 서비스 레벨 목표 ───────────────────────────────────────────────────── -@router.get("/slo") -async def list_slo(): - return {"slos": [ - {"service": "guardia-itsm", "availability_target": 99.9, "current": 99.95, "ok": True}, - {"service": "guardia-manager", "availability_target": 99.5, "current": 99.8, "ok": True}, - {"service": "guardia-messenger", "availability_target": 99.0, "current": 98.7, "ok": False}, - ]} - -@router.post("/slo") -async def create_slo(service: str, availability_target: float, latency_p95_ms: int = 500): - slid = f"SLO-{uuid.uuid4().hex[:8].upper()}" - _service_levels[slid] = {"id": slid, "service": service, - "availability_target": availability_target, - "latency_p95_ms": latency_p95_ms, - "created_at": datetime.utcnow().isoformat()} - return _service_levels[slid] - -@router.get("/health") -async def platform_health(): - return {"status": "healthy", "services_monitored": len(_catalog), - "environments": len(_environments) or 2, "templates": 6} diff --git a/routers/public_sector2.py b/routers/public_sector2.py deleted file mode 100644 index 64977bd..0000000 --- a/routers/public_sector2.py +++ /dev/null @@ -1,202 +0,0 @@ -""" -GUARDiA 공공기관 특화 v2 — Gen6 -K-CSAP v2·행정망 연동·나라장터 v2·행정전자서명·공공 클라우드·ISP 수립 -""" -import uuid -from datetime import datetime, timedelta -from typing import Any, Dict, List, Optional -from fastapi import APIRouter, HTTPException, Query -from pydantic import BaseModel - -router = APIRouter(prefix="/api/public2", tags=["Public Sector v2"]) - -_csap_checks: Dict[str, Dict] = {} -_procurement: Dict[str, Dict] = {} -_admin_net: Dict[str, Dict] = {} -_isp_plans: Dict[str, Dict] = {} -_signatures: Dict[str, Dict] = {} - -class CSAPAuditCreate(BaseModel): - institution: str; audit_type: str = "quarterly" # quarterly|annual|special - scope: List[str] = ["all"] - -class ProcurementCreate(BaseModel): - title: str; amount: float; category: str - contract_no: str = ""; start_date: str = ""; end_date: str = "" - -class AdminNetRequest(BaseModel): - zone: str # admin|internet|dmz - service: str; protocol: str = "https" - approved_by: str - -class ISPCreate(BaseModel): - institution: str; fiscal_year: int - total_budget: float; it_budget_ratio: float = 0.05 - -class ESignRequest(BaseModel): - document_id: str; signer: str; signature_type: str = "gpki" # gpki|accredited|rsa - -# ── K-CSAP v2 ──────────────────────────────────────────────────────────── -@router.post("/csap/audit") -async def create_csap_audit(audit: CSAPAuditCreate): - aid = f"CSAP-{uuid.uuid4().hex[:8].upper()}" - _csap_checks[aid] = {**audit.model_dump(), "id": aid, "status": "in_progress", - "compliance_rate": 0, "started_at": datetime.utcnow().isoformat()} - return _csap_checks[aid] - -@router.get("/csap/audits") -async def list_csap_audits(): return {"audits": list(_csap_checks.values())} - -@router.get("/csap/controls") -async def csap_controls(): - """CSAP 보안통제 항목 전체 목록.""" - return {"categories": [ - {"id": "M", "name": "관리적 보안", "items": 45, "passed": 42, "rate": 93.3}, - {"id": "P", "name": "물리적 보안", "items": 20, "passed": 19, "rate": 95.0}, - {"id": "T", "name": "기술적 보안", "items": 80, "passed": 73, "rate": 91.3}, - ], "total": 145, "passed": 134, "overall_rate": 92.4} - -@router.get("/csap/report/{aid}") -async def csap_report(aid: str): - audit = _csap_checks.get(aid) - if not audit: raise HTTPException(404) - return {**audit, "compliance_rate": 92.4, - "findings": [{"control": "T-3.2", "status": "미흡", "recommendation": "패스워드 정책 강화"}, - {"control": "M-1.5", "status": "보완", "recommendation": "보안 교육 주기 단축"}], - "next_audit": (datetime.utcnow() + timedelta(days=90)).isoformat()} - -@router.get("/csap/gap-analysis") -async def csap_gap_analysis(institution: str = Query(...)): - return {"institution": institution, "gap_items": [ - {"control": "T-5.1", "current_state": "미구현", "target": "구현", "priority": "high"}, - {"control": "M-2.3", "current_state": "부분구현", "target": "완전구현", "priority": "medium"}, - ], "improvement_plan": "3개월 내 2개 항목 개선 계획"} - -@router.post("/csap/self-check") -async def csap_self_check(institution: str, category: str = "all"): - return {"institution": institution, "category": category, - "checked_at": datetime.utcnow().isoformat(), - "score": 92.4, "grade": "우수", - "action_items": 3, "status": "completed"} - -# ── 나라장터 v2 ──────────────────────────────────────────────────────────── -@router.post("/g2b/procurement") -async def register_procurement(proc: ProcurementCreate): - pid = f"G2B-{uuid.uuid4().hex[:8].upper()}" - _procurement[pid] = {**proc.model_dump(), "id": pid, "status": "registered", - "registered_at": datetime.utcnow().isoformat()} - return _procurement[pid] - -@router.get("/g2b/procurement") -async def list_procurement(status: Optional[str] = None): - procs = list(_procurement.values()) - if status: procs = [p for p in procs if p.get("status") == status] - return {"procurements": procs, "total": len(procs)} - -@router.get("/g2b/search") -async def search_g2b(keyword: str, category: str = "IT", page: int = 1): - return {"keyword": keyword, "category": category, "page": page, - "results": [ - {"id": "G2B-001", "title": f"[{category}] {keyword} 시스템 구축", "amount": 150000000, - "deadline": "2026-07-15", "status": "공고중"}, - {"id": "G2B-002", "title": f"{keyword} 유지보수 용역", "amount": 48000000, - "deadline": "2026-07-20", "status": "공고중"}, - ], "total": 2} - -@router.get("/g2b/contract/{cid}") -async def get_contract(cid: str): - return {"contract_id": cid, "title": "GUARDiA ITSM 유지보수", "amount": 48000000, - "period": "2026-01-01 ~ 2026-12-31", "status": "계약중", "vendor": "지오정보기술"} - -@router.post("/g2b/delivery-check") -async def delivery_check(contract_id: str, items: List[Dict[str, Any]]): - return {"contract_id": contract_id, "items_checked": len(items), - "status": "검수완료", "checked_at": datetime.utcnow().isoformat(), - "inspector": "담당자", "next_step": "세금계산서 발행"} - -# ── 행정망 연동 관리 ───────────────────────────────────────────────────── -@router.post("/admin-net/request") -async def request_admin_net(req: AdminNetRequest): - rid = f"NET-{uuid.uuid4().hex[:8].upper()}" - _admin_net[rid] = {**req.model_dump(), "id": rid, "status": "pending", - "requested_at": datetime.utcnow().isoformat()} - return _admin_net[rid] - -@router.get("/admin-net/topology") -async def admin_net_topology(): - return {"zones": [ - {"name": "행정망", "type": "admin", "services": ["ITSM", "CMDB"], "firewall_rules": 24}, - {"name": "인터넷망", "type": "internet", "services": ["Homepage"], "firewall_rules": 12}, - {"name": "DMZ", "type": "dmz", "services": ["Manager API"], "firewall_rules": 8}, - ], "connections": [ - {"from": "admin", "to": "dmz", "protocol": "https", "status": "active"}, - {"from": "internet", "to": "dmz", "protocol": "https", "status": "active"}, - ]} - -@router.get("/admin-net/firewall-rules") -async def firewall_rules(zone: Optional[str] = None): - rules = [ - {"id": "FW-001", "zone": "admin", "src": "10.0.0.0/8", "dst": "any", "port": 443, "action": "allow"}, - {"id": "FW-002", "zone": "internet", "src": "any", "dst": "DMZ", "port": 443, "action": "allow"}, - ] - if zone: rules = [r for r in rules if r["zone"] == zone] - return {"rules": rules, "total": len(rules)} - -# ── 행정전자서명 (GPKI) ──────────────────────────────────────────────────── -@router.post("/esign/request") -async def esign_request(req: ESignRequest): - sid = f"SIG-{uuid.uuid4().hex[:8].upper()}" - _signatures[sid] = {**req.model_dump(), "id": sid, "status": "pending", - "requested_at": datetime.utcnow().isoformat()} - return _signatures[sid] - -@router.post("/esign/verify") -async def esign_verify(signature_id: str): - sig = _signatures.get(signature_id) - if not sig: raise HTTPException(404) - return {"signature_id": signature_id, "valid": True, "signer": sig.get("signer"), - "signed_at": datetime.utcnow().isoformat(), "certificate": "행정기관인증서"} - -# ── ISP 수립 지원 v2 ────────────────────────────────────────────────────── -@router.post("/isp") -async def create_isp(isp: ISPCreate): - iid = f"ISP-{uuid.uuid4().hex[:8].upper()}" - _isp_plans[iid] = {**isp.model_dump(), "id": iid, "status": "draft", - "it_budget": isp.total_budget * isp.it_budget_ratio, - "created_at": datetime.utcnow().isoformat()} - return _isp_plans[iid] - -@router.get("/isp") -async def list_isp(): return {"plans": list(_isp_plans.values())} - -@router.get("/isp/{iid}/roadmap") -async def isp_roadmap(iid: str): - isp = _isp_plans.get(iid) - if not isp: raise HTTPException(404) - return {"isp_id": iid, "roadmap": [ - {"quarter": "Q1", "projects": ["ITSM 고도화"], "budget": 50000000}, - {"quarter": "Q2", "projects": ["보안 강화"], "budget": 30000000}, - {"quarter": "Q3", "projects": ["DR 구축"], "budget": 40000000}, - {"quarter": "Q4", "projects": ["사용자 교육"], "budget": 10000000}, - ]} - -# ── 공공 클라우드 (K-Cloud) ──────────────────────────────────────────────── -@router.get("/kcloud/status") -async def kcloud_status(): - return {"provider": "NCloud (공공)", "region": "kr-pub-1", - "services_deployed": 3, "cost_this_month": 1240000, - "compliance": "CSAP 인증 완료", "availability": "99.98%"} - -@router.get("/kcloud/pricing") -async def kcloud_pricing(resource_type: str = "compute"): - pricing = { - "compute": [{"spec": "2vCPU/4GB", "price_hour": 85, "price_month": 61200}], - "storage": [{"spec": "100GB SSD", "price_month": 15000}], - "network": [{"spec": "공인IP", "price_month": 6600}], - } - return {"resource_type": resource_type, "pricing": pricing.get(resource_type, [])} - -@router.get("/public2/health") -async def health(): - return {"status": "healthy", "csap_audits": len(_csap_checks), - "procurement": len(_procurement), "signatures": len(_signatures)} diff --git a/routers/search.py b/routers/search.py deleted file mode 100644 index 58cca15..0000000 --- a/routers/search.py +++ /dev/null @@ -1,138 +0,0 @@ -""" -통합 검색 API (모바일 기능 #50). - - GET /api/search/?q={query}&types=sr,server,kb,institution - -SR, 서버(CMDB), KB 문서, 기관을 동시에 검색하여 타입별 결과를 반환. -보안: 서버 결과는 ServerOut 안전 필드만 반환(ip_addr/ssh_user/os_pw_enc 제외). - CUSTOMER 역할은 자신의 기관 SR/서버만 조회. -""" -from __future__ import annotations - -from typing import Optional - -from fastapi import APIRouter, Depends, HTTPException, Query -from sqlalchemy import select, or_ -from sqlalchemy.ext.asyncio import AsyncSession - -from core.auth import get_current_user -from database import get_db -from models import ( - Institution, KBDocument, Server, SRRequest, User, UserRole, -) - -router = APIRouter(prefix="/api/search", tags=["Search"]) - -_PER_TYPE_LIMIT = 5 - - -async def _customer_inst_id(user: User, db: AsyncSession) -> Optional[int]: - """CUSTOMER 역할이면 소속 기관 id 반환, 아니면 None.""" - if user.role == UserRole.CUSTOMER and user.inst_code: - inst = (await db.execute( - select(Institution).where(Institution.inst_code == user.inst_code) - )).scalars().first() - return inst.id if inst else -1 - return None - - -@router.get("/") -async def global_search( - q: str = Query(..., min_length=1, description="검색어"), - types: str = Query("sr,server,kb,institution", description="콤마 구분 검색 대상"), - db: AsyncSession = Depends(get_db), - current_user: User = Depends(get_current_user), -): - """SR + 서버 + KB + 기관 통합 검색.""" - wanted = {t.strip() for t in types.split(",") if t.strip()} - if not wanted: - raise HTTPException(422, "types에 최소 하나의 검색 대상을 지정하세요.") - - like = f"%{q}%" - results: dict = {} - cust_inst_id = await _customer_inst_id(current_user, db) - - # ── SR 검색 ────────────────────────────────────────────────────────── - if "sr" in wanted: - sr_q = select(SRRequest).where( - or_(SRRequest.title.ilike(like), - SRRequest.description.ilike(like), - SRRequest.sr_id.ilike(like)) - ) - if cust_inst_id is not None: - sr_q = sr_q.where(SRRequest.inst_id == cust_inst_id) - sr_q = sr_q.order_by(SRRequest.created_at.desc()).limit(_PER_TYPE_LIMIT) - srs = (await db.execute(sr_q)).scalars().all() - results["sr"] = [ - { - "sr_id": s.sr_id, - "title": s.title, - "status": s.status, - "priority": s.priority, - "sr_type": s.sr_type, - } - for s in srs - ] - - # ── 서버(CMDB) 검색 — 자격증명 필드 절대 제외 ────────────────────────── - if "server" in wanted: - srv_q = select(Server).where( - or_(Server.server_name.ilike(like), - Server.server_role.ilike(like), - Server.os_type.ilike(like)) - ) - if cust_inst_id is not None: - srv_q = srv_q.where(Server.inst_id == cust_inst_id) - srv_q = srv_q.limit(_PER_TYPE_LIMIT) - servers = (await db.execute(srv_q)).scalars().all() - results["server"] = [ - { - "id": s.id, - "server_name": s.server_name, - "server_role": s.server_role, - "os_type": s.os_type, - "inst_id": s.inst_id, - # ip_addr / ssh_user / os_pw_enc 절대 미포함 - } - for s in servers - ] - - # ── KB 검색 ────────────────────────────────────────────────────────── - if "kb" in wanted: - kb_q = select(KBDocument).where( - or_(KBDocument.title.ilike(like), - KBDocument.symptoms.ilike(like), - KBDocument.tags.ilike(like)) - ).limit(_PER_TYPE_LIMIT) - kbs = (await db.execute(kb_q)).scalars().all() - results["kb"] = [ - { - "doc_id": k.doc_id, - "title": k.title, - "category": k.category, - "tags": k.tags, - } - for k in kbs - ] - - # ── 기관 검색 ──────────────────────────────────────────────────────── - if "institution" in wanted: - inst_q = select(Institution).where( - or_(Institution.inst_name.ilike(like), - Institution.inst_code.ilike(like)) - ) - if cust_inst_id is not None and cust_inst_id != -1: - inst_q = inst_q.where(Institution.id == cust_inst_id) - inst_q = inst_q.limit(_PER_TYPE_LIMIT) - insts = (await db.execute(inst_q)).scalars().all() - results["institution"] = [ - { - "id": i.id, - "inst_code": i.inst_code, - "inst_name": i.inst_name, - } - for i in insts - ] - - total = sum(len(v) for v in results.values()) - return {"query": q, "total": total, "results": results} diff --git a/routers/sr_chat.py b/routers/sr_chat.py deleted file mode 100644 index 4b4dc29..0000000 --- a/routers/sr_chat.py +++ /dev/null @@ -1,280 +0,0 @@ -""" -SR 채팅방 API + WebSocket (모바일 기능 #98). - - WS /ws/sr-chat/{sr_id}?token={jwt} — SR별 실시간 채팅 - POST /api/sr-chat/{sr_id}/messages — 메시지 전송 (REST) - GET /api/sr-chat/{sr_id}/messages — 메시지 이력 - POST /api/sr-chat/{sr_id}/read — 읽음 처리 - -메시지 타입: text | image | sr_update -""" -from __future__ import annotations - -import json -import logging -from datetime import datetime -from typing import Dict, List, Optional, Set - -from fastapi import ( - APIRouter, Depends, HTTPException, Query, WebSocket, WebSocketDisconnect, -) -from pydantic import BaseModel, ConfigDict -from sqlalchemy import select -from sqlalchemy.ext.asyncio import AsyncSession - -from core.auth import get_current_user -from database import get_db, SessionLocal -from models import SRChatMessage, SRRequest, User - -logger = logging.getLogger(__name__) -router = APIRouter(tags=["SR Chat"]) - -_VALID_MSG_TYPE = {"text", "image", "sr_update"} - - -# ── WebSocket 연결 관리 (SR방별 그룹) ───────────────────────────────────────── -class _ChatRooms: - def __init__(self) -> None: - # { sr_id: set(WebSocket) } - self._rooms: Dict[str, Set[WebSocket]] = {} - - def join(self, sr_id: str, ws: WebSocket) -> None: - self._rooms.setdefault(sr_id, set()).add(ws) - - def leave(self, sr_id: str, ws: WebSocket) -> None: - room = self._rooms.get(sr_id) - if room: - room.discard(ws) - if not room: - self._rooms.pop(sr_id, None) - - async def broadcast(self, sr_id: str, payload: dict) -> None: - room = self._rooms.get(sr_id) - if not room: - return - msg = json.dumps(payload, ensure_ascii=False) - dead = [] - for ws in list(room): - try: - await ws.send_text(msg) - except Exception: - dead.append(ws) - for ws in dead: - room.discard(ws) - - -rooms = _ChatRooms() - - -# ── 스키마 ──────────────────────────────────────────────────────────────────── -class ChatMessageCreate(BaseModel): - content: str - msg_type: str = "text" - - -class ChatMessageOut(BaseModel): - model_config = ConfigDict(from_attributes=True) - - id: int - task_id: str - sender_id: str - content: str - msg_type: str - created_at: Optional[datetime] - - -# ── 헬퍼 ────────────────────────────────────────────────────────────────────── -async def _ensure_sr(sr_id: str, db: AsyncSession) -> SRRequest: - sr = (await db.execute( - select(SRRequest).where(SRRequest.sr_id == sr_id) - )).scalars().first() - if not sr: - raise HTTPException(404, "SR을 찾을 수 없습니다.") - return sr - - -async def _save_message(db: AsyncSession, sr_id: str, sender: str, - content: str, msg_type: str) -> SRChatMessage: - if msg_type not in _VALID_MSG_TYPE: - raise HTTPException(422, f"msg_type은 {_VALID_MSG_TYPE} 중 하나여야 합니다.") - if not content or not content.strip(): - raise HTTPException(422, "메시지 내용이 비어 있습니다.") - m = SRChatMessage( - task_id=sr_id, - sender_id=sender, - content=content, - msg_type=msg_type, - read_by=json.dumps([sender], ensure_ascii=False), - ) - db.add(m) - await db.commit() - await db.refresh(m) - return m - - -# ── REST: 메시지 전송 ───────────────────────────────────────────────────────── -@router.post("/api/sr-chat/{sr_id}/messages", response_model=ChatMessageOut, status_code=201) -async def send_message( - sr_id: str, - payload: ChatMessageCreate, - db: AsyncSession = Depends(get_db), - current_user: User = Depends(get_current_user), -): - """SR 채팅 메시지 전송 (REST). 연결된 WebSocket 구독자에게도 브로드캐스트.""" - await _ensure_sr(sr_id, db) - m = await _save_message(db, sr_id, current_user.username, - payload.content, payload.msg_type) - await rooms.broadcast(sr_id, { - "type": "message", - "id": m.id, - "task_id": sr_id, - "sender_id": m.sender_id, - "content": m.content, - "msg_type": m.msg_type, - "created_at": m.created_at.isoformat() if m.created_at else None, - }) - return m - - -# ── REST: 메시지 이력 ───────────────────────────────────────────────────────── -@router.get("/api/sr-chat/{sr_id}/messages", response_model=List[ChatMessageOut]) -async def list_messages( - sr_id: str, - skip: int = 0, - limit: int = 100, - db: AsyncSession = Depends(get_db), - current_user: User = Depends(get_current_user), -): - """SR 채팅 메시지 이력 (오래된 순).""" - await _ensure_sr(sr_id, db) - rows = (await db.execute( - select(SRChatMessage) - .where(SRChatMessage.task_id == sr_id) - .order_by(SRChatMessage.created_at.asc()) - .offset(skip).limit(min(limit, 500)) - )).scalars().all() - return rows - - -# ── REST: 읽음 처리 ─────────────────────────────────────────────────────────── -@router.post("/api/sr-chat/{sr_id}/read") -async def mark_read( - sr_id: str, - db: AsyncSession = Depends(get_db), - current_user: User = Depends(get_current_user), -): - """현재 사용자가 SR 채팅의 모든 메시지를 읽음 처리.""" - await _ensure_sr(sr_id, db) - rows = (await db.execute( - select(SRChatMessage).where(SRChatMessage.task_id == sr_id) - )).scalars().all() - updated = 0 - for m in rows: - try: - readers = json.loads(m.read_by) if m.read_by else [] - except Exception: - readers = [] - if current_user.username not in readers: - readers.append(current_user.username) - m.read_by = json.dumps(readers, ensure_ascii=False) - updated += 1 - await db.commit() - return {"sr_id": sr_id, "marked_read": updated, "reader": current_user.username} - - -# ── WebSocket: 실시간 채팅 ──────────────────────────────────────────────────── -async def _authenticate_ws(token: str, db: AsyncSession) -> Optional[User]: - if not token: - return None - try: - from core.auth import SECRET_KEY, ALGORITHM - from jose import jwt - payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM]) - if payload.get("mfa_pending"): - return None - username = payload.get("sub") - if not username: - return None - user = (await db.execute( - select(User).where(User.username == username) - )).scalars().first() - return user if (user and user.is_active) else None - except Exception: - return None - - -@router.websocket("/ws/sr-chat/{sr_id}") -async def sr_chat_ws( - websocket: WebSocket, - sr_id: str, - token: str = Query(..., description="JWT access_token"), - db: AsyncSession = Depends(get_db), -): - """SR별 실시간 채팅 WebSocket.""" - user = await _authenticate_ws(token, db) - if not user: - await websocket.close(code=4001, reason="인증 실패: 유효한 토큰이 필요합니다.") - return - - # SR 존재 확인 - sr = (await db.execute( - select(SRRequest).where(SRRequest.sr_id == sr_id) - )).scalars().first() - if not sr: - await websocket.close(code=4004, reason="SR을 찾을 수 없습니다.") - return - - await websocket.accept() - rooms.join(sr_id, websocket) - await websocket.send_text(json.dumps({ - "type": "connected", - "sr_id": sr_id, - "username": user.username, - "server_time": datetime.now().isoformat(), - }, ensure_ascii=False)) - - try: - while True: - raw = await websocket.receive_text() - try: - data = json.loads(raw) - except Exception: - await websocket.send_text(json.dumps( - {"type": "error", "message": "JSON 형식이 아닙니다."}, - ensure_ascii=False)) - continue - - if data.get("type") == "ping": - await websocket.send_text(json.dumps( - {"type": "pong", "server_time": datetime.now().isoformat()}, - ensure_ascii=False)) - continue - - content = (data.get("content") or "").strip() - msg_type = data.get("msg_type", "text") - if not content or msg_type not in _VALID_MSG_TYPE: - await websocket.send_text(json.dumps( - {"type": "error", "message": "content 또는 msg_type이 올바르지 않습니다."}, - ensure_ascii=False)) - continue - - # DB 저장 (독립 세션) + 구독자에게 브로드캐스트 - async with SessionLocal() as _db: - m = await _save_message(_db, sr_id, user.username, content, msg_type) - payload = { - "type": "message", - "id": m.id, - "task_id": sr_id, - "sender_id": m.sender_id, - "content": m.content, - "msg_type": m.msg_type, - "created_at": m.created_at.isoformat() if m.created_at else None, - } - await rooms.broadcast(sr_id, payload) - - except WebSocketDisconnect: - pass - except Exception as exc: - logger.debug("SR 채팅 WS 오류: sr=%s err=%s", sr_id, exc) - finally: - rooms.leave(sr_id, websocket) diff --git a/routers/stats.py b/routers/stats.py deleted file mode 100644 index 97ad683..0000000 --- a/routers/stats.py +++ /dev/null @@ -1,168 +0,0 @@ -""" -통계·보고 API (모바일 기능 #93~#97). - -GET /api/stats/my — 나의 SR 처리 통계 -GET /api/stats/institutions — 기관별 SR 현황 비교 -GET /api/stats/deploy-history — 배포 이력 타임라인 (VibeSession) -GET /api/stats/kpi — KPI 대시보드 -GET /api/stats/export-pdf — 리포트 JSON (앱에서 PDF 변환) -""" -from __future__ import annotations - -from datetime import datetime, timedelta -from typing import Optional - -from fastapi import APIRouter, Depends -from sqlalchemy import select, func, case -from sqlalchemy.ext.asyncio import AsyncSession - -from core.auth import get_current_user -from database import get_db -from models import ( - SRRequest, SRStatus, Institution, User, UserRole, VibeSession, -) - -router = APIRouter(prefix="/api/stats", tags=["Statistics"]) - - -def _this_month(): - now = datetime.now() - return datetime(now.year, now.month, 1) - - -async def _inst_ids_for(user: User, db: AsyncSession): - if user.role != UserRole.CUSTOMER: - return None - rows = (await db.execute( - select(Institution.inst_id).where(Institution.inst_code == user.inst_code) - )).scalars().all() - return rows or [-1] - - -@router.get("/my") -async def my_stats( - db: AsyncSession = Depends(get_db), - current_user: User = Depends(get_current_user), -): - now = datetime.now() - this_m = datetime(now.year, now.month, 1) - last_m = datetime(now.year, now.month - 1, 1) if now.month > 1 else datetime(now.year - 1, 12, 1) - - base = select(SRRequest).where(SRRequest.requested_by == current_user.username) - - async def _count(q): - return (await db.execute(select(func.count()).select_from(q.subquery()))).scalar_one() - - total = await _count(base) - this_done = await _count(base.where(SRRequest.status == SRStatus.COMPLETED, SRRequest.created_at >= this_m)) - last_done = await _count(base.where(SRRequest.status == SRStatus.COMPLETED, SRRequest.created_at >= last_m, SRRequest.created_at < this_m)) - this_all = await _count(base.where(SRRequest.created_at >= this_m)) - last_all = await _count(base.where(SRRequest.created_at >= last_m, SRRequest.created_at < this_m)) - - return { - "total": total, - "this_month": {"created": this_all, "completed": this_done, "rate": round(this_done / this_all * 100, 1) if this_all else 0}, - "last_month": {"created": last_all, "completed": last_done, "rate": round(last_done / last_all * 100, 1) if last_all else 0}, - } - - -@router.get("/institutions") -async def institution_stats( - db: AsyncSession = Depends(get_db), - current_user: User = Depends(get_current_user), -): - q = ( - select( - Institution.inst_id, - Institution.inst_name, - func.count(SRRequest.sr_id).label("total"), - func.sum(case((SRRequest.status == SRStatus.COMPLETED, 1), else_=0)).label("done"), - ) - .outerjoin(SRRequest, SRRequest.inst_id == Institution.inst_id) - .group_by(Institution.inst_id, Institution.inst_name) - .order_by(func.count(SRRequest.sr_id).desc()) - ) - rows = (await db.execute(q)).all() - return { - "items": [ - { - "inst_id": r.inst_id, - "inst_name": r.inst_name, - "total": r.total or 0, - "completed": r.done or 0, - "rate": round((r.done or 0) / r.total * 100, 1) if r.total else 0, - } - for r in rows - ] - } - - -@router.get("/deploy-history") -async def deploy_history( - limit: int = 30, - db: AsyncSession = Depends(get_db), - current_user: User = Depends(get_current_user), -): - q = ( - select(VibeSession) - .order_by(VibeSession.started_at.desc()) - .limit(limit) - ) - rows = (await db.execute(q)).scalars().all() - return { - "items": [ - { - "id": r.id, - "project": r.project_name if hasattr(r, "project_name") else "N/A", - "status": r.status, - "started_at": r.started_at.isoformat() if r.started_at else None, - "deployed_at": r.deployed_at.isoformat() if r.deployed_at else None, - "duration_sec": int((r.deployed_at - r.started_at).total_seconds()) if r.deployed_at and r.started_at else None, - "deployed_by": r.requested_by if hasattr(r, "requested_by") else None, - } - for r in rows - ] - } - - -@router.get("/kpi") -async def kpi_dashboard( - db: AsyncSession = Depends(get_db), - current_user: User = Depends(get_current_user), -): - now = datetime.now() - month_start = datetime(now.year, now.month, 1) - - total_sr = (await db.execute(select(func.count(SRRequest.sr_id)).where(SRRequest.created_at >= month_start))).scalar_one() - done_sr = (await db.execute(select(func.count(SRRequest.sr_id)).where(SRRequest.created_at >= month_start, SRRequest.status == SRStatus.COMPLETED))).scalar_one() - breach = (await db.execute(select(func.count(SRRequest.sr_id)).where(SRRequest.created_at >= month_start, SRRequest.sla_breached == True))).scalar_one() - - return { - "period": month_start.strftime("%Y-%m"), - "sr_completion_rate": round(done_sr / total_sr * 100, 1) if total_sr else 0, - "sla_compliance_rate": round((total_sr - breach) / total_sr * 100, 1) if total_sr else 100, - "total_sr": total_sr, - "completed_sr": done_sr, - "sla_breach": breach, - "csap_score": 82.5, - "targets": { - "sr_completion_rate": 90, - "sla_compliance_rate": 95, - "csap_score": 85, - }, - } - - -@router.get("/export-pdf") -async def export_pdf_data( - db: AsyncSession = Depends(get_db), - current_user: User = Depends(get_current_user), -): - kpi = await kpi_dashboard(db=db, current_user=current_user) - my = await my_stats(db=db, current_user=current_user) - return { - "generated_at": datetime.now().isoformat(), - "generated_by": current_user.username, - "kpi": kpi, - "my_stats": my, - } diff --git a/routers/system.py b/routers/system.py deleted file mode 100644 index 1f4e872..0000000 --- a/routers/system.py +++ /dev/null @@ -1,92 +0,0 @@ -""" -시스템 정보 API (모바일 기능 #77). - - GET /api/system/release-notes — 버전별 릴리즈 노트 목록 - GET /api/system/version — 현재 버전 정보 - -릴리즈 노트는 정적 정의(코드 내장)로 제공한다. -""" -from __future__ import annotations - -from typing import List, Optional - -from fastapi import APIRouter, Depends -from pydantic import BaseModel - -from core.auth import get_current_user -from models import User - -router = APIRouter(prefix="/api/system", tags=["System"]) - -CURRENT_VERSION = "2.0.0" - -# 최신 → 과거 순서 -_RELEASE_NOTES: List[dict] = [ - { - "version": "2.0.0", - "date": "2026-06-06", - "changes": [ - "모바일 100기능 백엔드 API 추가 (알림규칙·통합검색·SR채팅·부품재고)", - "SR 에스컬레이션/구독/만족도/현장서명/체크인 지원", - "보안 이벤트 로그 및 디바이스 관리 추가", - "다단계 승인 현황 및 변경 달력 API 추가", - ], - "breaking_changes": [], - }, - { - "version": "1.5.0", - "date": "2026-06-01", - "changes": [ - "CI/CD 배포 트리거 연동", - "tmux 세션 관리·하네스 빌더 추가", - "AI-SOC·데이터 거버넌스 영역 확장", - ], - "breaking_changes": [], - }, - { - "version": "1.0.0", - "date": "2026-05-20", - "changes": [ - "GUARDiA ITSM 정식 출시", - "SR 라이프사이클·CMDB·KB·SLA·승인 워크플로우", - ], - "breaking_changes": [], - }, -] - - -class ReleaseNote(BaseModel): - version: str - date: str - changes: List[str] - breaking_changes: List[str] = [] - - -@router.get("/release-notes", response_model=List[ReleaseNote]) -async def list_release_notes( - since: Optional[str] = None, - _u: User = Depends(get_current_user), -): - """버전별 릴리즈 노트 목록 (최신순). since 지정 시 해당 버전 이후만.""" - notes = _RELEASE_NOTES - if since: - # since 버전 이후(미포함)만 반환 - filtered = [] - for n in notes: - if n["version"] == since: - break - filtered.append(n) - notes = filtered - return notes - - -@router.get("/version") -async def get_version(_u: User = Depends(get_current_user)): - """현재 버전 정보.""" - latest = _RELEASE_NOTES[0] if _RELEASE_NOTES else None - return { - "name": "GUARDiA ITSM", - "version": CURRENT_VERSION, - "latest_release_date": latest["date"] if latest else None, - "release_count": len(_RELEASE_NOTES), - } diff --git a/routers/tasks.py b/routers/tasks.py index dc82067..0e7d8cf 100644 --- a/routers/tasks.py +++ b/routers/tasks.py @@ -13,8 +13,7 @@ from core.events import broadcast from database import get_db from models import ( AuditLog, Institution, SRCreate, SROut, SRRequest, SRStatus, - SRStatusUpdate, SRType, User, compute_log_hash, - SRSubscription, SRRating, SRSignature, SRCheckin, + SRStatusUpdate, SRType, User, compute_log_hash ) router = APIRouter(prefix="/api/tasks", tags=["tasks"]) @@ -508,301 +507,3 @@ async def bulk_sr_action( "results": results, } - -# ════════════════════════════════════════════════════════════════════════════════ -# ── 모바일 100기능: SR 액션 + 통계 ────────────────────────────────────────────── -# ════════════════════════════════════════════════════════════════════════════════ - -class EscalateRequest(BaseModel): - reason: Optional[str] = None - escalate_to: Optional[str] = None # 미지정 시 온콜 에스컬레이션 체인 사용 - - -class RatingRequest(BaseModel): - score: int - comment: Optional[str] = None - - -class SignatureRequest(BaseModel): - signature_base64: str - - -class SlaExceptionRequest(BaseModel): - reason: str - new_deadline: str # ISO datetime/date - - -class CheckinRequest(BaseModel): - lat: float - lng: float - - -async def _get_sr(sr_id: str, db: AsyncSession) -> SRRequest: - sr = (await db.execute( - select(SRRequest).where(SRRequest.sr_id == sr_id) - )).scalars().first() - if not sr: - raise HTTPException(404, detail="SR을 찾을 수 없습니다.") - return sr - - -@router.post("/{sr_id}/escalate") -async def escalate_task( - sr_id: str, - payload: EscalateRequest, - db: AsyncSession = Depends(get_db), - current_user: User = Depends(get_current_user), -): - """SR 에스컬레이션 — 상위 담당자로 재배정 + 알림 (#8).""" - from models import UserRole - if current_user.role == UserRole.CUSTOMER: - raise HTTPException(403, "에스컬레이션 권한이 없습니다.") - - sr = await _get_sr(sr_id, db) - - target = payload.escalate_to - if not target: - # 온콜 에스컬레이션 체인에서 대상 도출 시도 - try: - from core.oncall_rotate import get_current_oncall - sched = await get_current_oncall(db) - if sched: - target = sched.escalation_to or sched.backup_engineer or sched.engineer - except Exception: - target = None - - sr.escalated_at = datetime.now() - sr.escalated_to = target - if target: - sr.assigned_to = target - sr.updated_at = datetime.now() - await _write_audit(db, sr_id, current_user.username, "SR_ESCALATED", - f"에스컬레이션 → {target or '미정'} | 사유: {payload.reason or ''}") - await db.commit() - await broadcast("sla_escalated", { - "sr_id": sr_id, "escalated_to": target, "by": current_user.username, - }) - return { - "sr_id": sr_id, - "escalated_to": target, - "escalated_at": sr.escalated_at.isoformat(), - "message": f"'{target or '대상 미정'}'(으)로 에스컬레이션되었습니다.", - } - - -@router.post("/{sr_id}/subscribe") -async def toggle_subscribe( - sr_id: str, - db: AsyncSession = Depends(get_db), - current_user: User = Depends(get_current_user), -): - """SR 구독/팔로우 토글 (#14).""" - await _get_sr(sr_id, db) - existing = (await db.execute( - select(SRSubscription).where( - SRSubscription.task_id == sr_id, - SRSubscription.username == current_user.username, - ) - )).scalars().first() - - if existing: - await db.delete(existing) - await db.commit() - return {"sr_id": sr_id, "subscribed": False, "message": "구독을 해제했습니다."} - - sub = SRSubscription(task_id=sr_id, username=current_user.username) - db.add(sub) - await db.commit() - return {"sr_id": sr_id, "subscribed": True, "message": "구독했습니다."} - - -@router.post("/{sr_id}/rating", status_code=201) -async def rate_task( - sr_id: str, - payload: RatingRequest, - db: AsyncSession = Depends(get_db), - current_user: User = Depends(get_current_user), -): - """완료 SR 만족도 평가 (#53).""" - if not (1 <= payload.score <= 5): - raise HTTPException(422, "score는 1~5 범위여야 합니다.") - sr = await _get_sr(sr_id, db) - if sr.status != SRStatus.COMPLETED: - raise HTTPException(400, "완료된 SR만 평가할 수 있습니다.") - - rating = SRRating( - task_id=sr_id, rater=current_user.username, - score=payload.score, comment=payload.comment, - ) - db.add(rating) - await _write_audit(db, sr_id, current_user.username, "SR_RATED", - f"만족도 {payload.score}점") - await db.commit() - await db.refresh(rating) - return {"sr_id": sr_id, "score": payload.score, "rating_id": rating.id} - - -@router.post("/{sr_id}/signature", status_code=201) -async def save_signature( - sr_id: str, - payload: SignatureRequest, - db: AsyncSession = Depends(get_db), - current_user: User = Depends(get_current_user), -): - """현장 전자서명 저장 (#70).""" - if not payload.signature_base64 or len(payload.signature_base64) < 10: - raise HTTPException(422, "signature_base64 데이터가 올바르지 않습니다.") - await _get_sr(sr_id, db) - - sig = SRSignature( - task_id=sr_id, signed_by=current_user.username, - signature_b64=payload.signature_base64, - ) - db.add(sig) - await _write_audit(db, sr_id, current_user.username, "SR_SIGNED", "현장 서명 등록") - await db.commit() - await db.refresh(sig) - return {"sr_id": sr_id, "signature_id": sig.id, "signed_by": current_user.username} - - -@router.post("/{sr_id}/sla-exception") -async def request_sla_exception( - sr_id: str, - payload: SlaExceptionRequest, - db: AsyncSession = Depends(get_db), - current_user: User = Depends(get_current_user), -): - """SLA 예외 승인 요청 — SLA 마감 연장 요청 (#94).""" - sr = await _get_sr(sr_id, db) - try: - new_dl = datetime.fromisoformat(payload.new_deadline) - except ValueError: - raise HTTPException(422, "new_deadline은 ISO 날짜/시간 형식이어야 합니다.") - - old_dl = sr.sla_deadline - sr.sla_deadline = new_dl - sr.sla_breached = False - sr.updated_at = datetime.now() - await _write_audit(db, sr_id, current_user.username, "SLA_EXCEPTION_REQUESTED", - f"SLA 마감 {old_dl} → {new_dl} | 사유: {payload.reason}") - await db.commit() - return { - "sr_id": sr_id, - "old_deadline": old_dl.isoformat() if old_dl else None, - "new_deadline": new_dl.isoformat(), - "reason": payload.reason, - "message": "SLA 예외(마감 연장)가 적용되었습니다.", - } - - -@router.post("/{sr_id}/checkin", status_code=201) -async def checkin_task( - sr_id: str, - payload: CheckinRequest, - db: AsyncSession = Depends(get_db), - current_user: User = Depends(get_current_user), -): - """현장 체크인 — GPS 좌표 기록 (#93).""" - if not (-90 <= payload.lat <= 90) or not (-180 <= payload.lng <= 180): - raise HTTPException(422, "좌표 값이 올바르지 않습니다.") - await _get_sr(sr_id, db) - - chk = SRCheckin( - task_id=sr_id, username=current_user.username, - lat=payload.lat, lng=payload.lng, - ) - db.add(chk) - await _write_audit(db, sr_id, current_user.username, "SR_CHECKIN", - f"현장 체크인 ({payload.lat}, {payload.lng})") - await db.commit() - await db.refresh(chk) - return { - "sr_id": sr_id, - "checkin_id": chk.id, - "lat": payload.lat, - "lng": payload.lng, - "checked_in_at": chk.created_at.isoformat() if chk.created_at else None, - } - - -@router.get("/stats/mine") -async def my_sr_stats( - db: AsyncSession = Depends(get_db), - current_user: User = Depends(get_current_user), -): - """내 SR 처리 통계 — total / closed / avg_resolve_hours / sla_met_rate (#3).""" - rows = (await db.execute( - select(SRRequest).where(SRRequest.assigned_to == current_user.username) - )).scalars().all() - - total = len(rows) - terminal = {SRStatus.COMPLETED, SRStatus.REJECTED, SRStatus.FAILED_ROLLBACK} - closed_rows = [r for r in rows if r.status in terminal] - closed = len(closed_rows) - - # 평균 해결 시간 (완료 건의 created_at→updated_at) - completed = [r for r in rows if r.status == SRStatus.COMPLETED - and r.created_at and r.updated_at] - avg_hours = 0.0 - if completed: - secs = sum((r.updated_at - r.created_at).total_seconds() for r in completed) - avg_hours = round(secs / len(completed) / 3600, 2) - - # SLA 준수율 (마감 시각이 있는 건 중 미위반 비율) - sla_rows = [r for r in rows if r.sla_deadline is not None] - sla_met = len([r for r in sla_rows if not r.sla_breached]) - sla_met_rate = round(sla_met / len(sla_rows) * 100, 1) if sla_rows else 100.0 - - return { - "username": current_user.username, - "total": total, - "closed": closed, - "open": total - closed, - "avg_resolve_hours": avg_hours, - "sla_met_rate": sla_met_rate, - } - - -@router.get("/stats/by-institution") -async def stats_by_institution( - db: AsyncSession = Depends(get_db), - current_user: User = Depends(get_current_user), -): - """기관별 SR 현황 비교 (#4). CUSTOMER는 자기 기관만.""" - from models import UserRole - - q = select(SRRequest) - cust_inst_id = None - if current_user.role == UserRole.CUSTOMER and current_user.inst_code: - inst = (await db.execute( - select(Institution).where(Institution.inst_code == current_user.inst_code) - )).scalars().first() - cust_inst_id = inst.id if inst else -1 - q = q.where(SRRequest.inst_id == cust_inst_id) - - srs = (await db.execute(q)).scalars().all() - - # 기관 이름 매핑 - insts = (await db.execute(select(Institution))).scalars().all() - name_map = {i.id: i.inst_name for i in insts} - - terminal = {SRStatus.COMPLETED, SRStatus.REJECTED, SRStatus.FAILED_ROLLBACK} - agg: dict = {} - for s in srs: - key = s.inst_id - bucket = agg.setdefault(key, { - "inst_id": key, - "inst_name": name_map.get(key, "미지정"), - "total": 0, "closed": 0, "open": 0, "sla_breached": 0, - }) - bucket["total"] += 1 - if s.status in terminal: - bucket["closed"] += 1 - else: - bucket["open"] += 1 - if s.sla_breached: - bucket["sla_breached"] += 1 - - result = sorted(agg.values(), key=lambda x: x["total"], reverse=True) - return {"institutions": result, "count": len(result)} -