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 51a7503..0000000 Binary files a/guardia_itsm.db and /dev/null differ diff --git a/main.py b/main.py index f45d37a..fb4fb28 100644 --- a/main.py +++ b/main.py @@ -75,6 +75,8 @@ from routers import ( data_sync, # Gen6 추가 (2026-06-07) mcp_agents, platform_eng, advanced_security2, data_ai2, public_sector2, infra_native, + # 나라장터 사업 기회 분석 (2026-06-07) + g2b_opportunity, ) @@ -569,6 +571,7 @@ app.include_router(advanced_security2.router) # 고급 보안 v2 (ZTNA v2·S app.include_router(data_ai2.router) # 데이터 AI v2 (벡터DB·RAG·LoRA API·임베딩) app.include_router(public_sector2.router) # 공공기관 특화 v2 (K-CSAP·나라장터·GPKI·ISP) app.include_router(infra_native.router) # 클라우드 네이티브 (eBPF·Wasm·서비스메시·이벤트소싱) +app.include_router(g2b_opportunity.router) # 나라장터 사업 기회 분석 + GUARDiA 적용성 # ── 개방망 보안 헤더 미들웨어 ──────────────────────────────────────────────── @app.middleware("http") diff --git a/routers/advanced_security2.py b/routers/advanced_security2.py deleted file mode 100644 index 3b28b14..0000000 --- a/routers/advanced_security2.py +++ /dev/null @@ -1,206 +0,0 @@ -""" -GUARDiA 고급 보안 v2 (Advanced Security Gen6) -ZTNA v2·SBOM v2·공급망 보안 v2·제로데이 추적·IAM 감사·포렌식 -""" -import uuid, json -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/security2", tags=["Advanced Security v2"]) - -_policies: Dict[str, Dict] = {} -_sboms: Dict[str, Dict] = {} -_incidents: Dict[str, Dict] = {} -_iam_audits: Dict[str, Dict] = {} -_threat_intel: List[Dict] = [] -_forensics: Dict[str, Dict] = {} - -class ZTNAPolicy(BaseModel): - name: str; resource: str; principal: str - conditions: Dict[str, Any] = {}; action: str = "allow" - ttl_hours: int = 24 - -class SBOMCreate(BaseModel): - project: str; version: str; format: str = "cyclonedx" # cyclonedx|spdx - components: List[Dict[str, str]] = [] - -class ThreatIntel(BaseModel): - ioc_type: str; value: str; severity: str = "medium" - source: str = "internal"; confidence: float = 0.8 - -class IAMAuditQuery(BaseModel): - user: Optional[str] = None; resource: Optional[str] = None - action: Optional[str] = None; from_date: Optional[str] = None - -class ForensicCapture(BaseModel): - target: str; reason: str; capture_type: str = "memory" # memory|disk|network|process - authorized_by: str - -class ZeroTrustScore(BaseModel): - entity: str; entity_type: str = "user" # user|device|service - -# ── ZTNA v2 정책 엔진 ───────────────────────────────────────────────────── -@router.post("/ztna/policies") -async def create_ztna_policy(p: ZTNAPolicy): - pid = f"ZTNA-{uuid.uuid4().hex[:8].upper()}" - expiry = (datetime.utcnow() + timedelta(hours=p.ttl_hours)).isoformat() - _policies[pid] = {**p.model_dump(), "id": pid, "status": "active", - "expires_at": expiry, "created_at": datetime.utcnow().isoformat()} - return _policies[pid] - -@router.get("/ztna/policies") -async def list_ztna_policies(resource: Optional[str] = None): - pols = list(_policies.values()) - if resource: pols = [p for p in pols if p["resource"] == resource] - return {"policies": pols, "total": len(pols)} - -@router.delete("/ztna/policies/{pid}") -async def revoke_policy(pid: str): - p = _policies.pop(pid, None) - if not p: raise HTTPException(404) - return {"revoked": pid} - -@router.post("/ztna/evaluate") -async def evaluate_access(principal: str, resource: str, action: str = "read"): - matched = [p for p in _policies.values() - if p["principal"] == principal and p["resource"] == resource] - allowed = any(p["action"] == "allow" for p in matched) - return {"principal": principal, "resource": resource, "action": action, - "decision": "allow" if allowed else "deny", - "matched_policies": [p["id"] for p in matched], - "reason": "Policy match" if matched else "No matching policy — default deny", - "ts": datetime.utcnow().isoformat()} - -@router.post("/ztna/score") -async def zero_trust_score(req: ZeroTrustScore): - import random - score = round(random.uniform(60, 95), 1) - return {"entity": req.entity, "entity_type": req.entity_type, - "trust_score": score, "level": "high" if score > 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)} -