fix: ITSM 라우터 15개 파일 git 추적 누락 복구 (Gitea 자동동기화 삭제 방지) [auto-sync]
This commit is contained in:
parent
73e30fb7c1
commit
7e92e1da09
206
routers/advanced_security2.py
Normal file
206
routers/advanced_security2.py
Normal file
@ -0,0 +1,206 @@
|
||||
"""
|
||||
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)}
|
||||
198
routers/alert_rules.py
Normal file
198
routers/alert_rules.py
Normal file
@ -0,0 +1,198 @@
|
||||
"""
|
||||
알림 규칙 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(>/</=), channel(push/inapp/sms), enabled
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import List, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel, ConfigDict, field_validator
|
||||
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 AlertRule, User
|
||||
|
||||
router = APIRouter(prefix="/api/alert-rules", tags=["Alert Rules"])
|
||||
|
||||
_VALID_TARGET = {"server", "service", "sr"}
|
||||
_VALID_METRIC = {"cpu", "memory", "disk", "sla"}
|
||||
_VALID_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
|
||||
110
routers/cicd.py
Normal file
110
routers/cicd.py
Normal file
@ -0,0 +1,110 @@
|
||||
"""
|
||||
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)
|
||||
261
routers/data_ai2.py
Normal file
261
routers/data_ai2.py
Normal file
@ -0,0 +1,261 @@
|
||||
"""
|
||||
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)}
|
||||
317
routers/g2b_opportunity.py
Normal file
317
routers/g2b_opportunity.py
Normal file
@ -0,0 +1,317 @@
|
||||
"""
|
||||
나라장터(G2B) 소프트웨어 개발 용역 크롤링 + GUARDiA 적용성 분석
|
||||
- 개방망: data.go.kr 나라장터 API 호출 가능
|
||||
- 폐쇄망: 캐시/샘플 데이터로 동작
|
||||
"""
|
||||
import os, json, httpx, asyncio
|
||||
from datetime import datetime, timedelta
|
||||
from fastapi import APIRouter, HTTPException, Query, BackgroundTasks
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional, List
|
||||
|
||||
router = APIRouter(prefix="/api/g2b-opportunity", tags=["나라장터 사업 기회"])
|
||||
|
||||
_OPEN = os.environ.get("GUARDIA_NETWORK_MODE") == "open"
|
||||
|
||||
# 나라장터 공공데이터 포털 API 설정 (개방망)
|
||||
G2B_API_BASE = "https://apis.data.go.kr/1230000/ScsbidInfoService"
|
||||
G2B_API_KEY = os.environ.get("G2B_API_KEY", "") # 공공데이터포털 인증키
|
||||
|
||||
# IT 프로젝트 분류 → GUARDiA 적용성 매핑
|
||||
GUARDIA_CATEGORY_MAP = {
|
||||
"IT운영유지보수": {
|
||||
"score": 98,
|
||||
"modules": ["ITSM", "SR관리", "배포자동화", "CMDB", "SLA관리"],
|
||||
"reason": "IT 인프라 운영·유지보수 업무 전반이 GUARDiA 핵심 대상",
|
||||
"proposal": "GUARDiA ITSM으로 SR 자동화·AI분류·SLA관리·배포자동화 전체 커버",
|
||||
},
|
||||
"정보보안": {
|
||||
"score": 92,
|
||||
"modules": ["AI-SOC", "CSAP", "보안감사", "취약점관리", "Zero Trust"],
|
||||
"reason": "공공기관 보안 감사·CSAP 준수 자동화가 GUARDiA 핵심 강점",
|
||||
"proposal": "GUARDiA AI-SOC+CSAP자동점검+취약점CVE 대응 패키지 제안",
|
||||
},
|
||||
"클라우드인프라": {
|
||||
"score": 87,
|
||||
"modules": ["서버관리", "CMDB", "용량예측", "비용최적화", "디지털트윈"],
|
||||
"reason": "클라우드 리소스 모니터링·비용 관리·서버 CMDB 자동발견 적용 가능",
|
||||
"proposal": "GUARDiA CMDB 자동발견+FinOps+예측 용량 계획 패키지",
|
||||
},
|
||||
"AI데이터분석": {
|
||||
"score": 81,
|
||||
"modules": ["AI플랫폼", "RAG", "지식그래프", "이상탐지", "예측분석"],
|
||||
"reason": "온프레미스 AI 플랫폼(Ollama)·RAG 지식베이스·이상탐지 모듈 활용",
|
||||
"proposal": "GUARDiA AI Brain(Ollama) + 지식그래프 + 이상탐지 패키지 제안",
|
||||
},
|
||||
"전자정부시스템": {
|
||||
"score": 75,
|
||||
"modules": ["민원포털", "전자서명", "나라장터연동", "GPKI"],
|
||||
"reason": "공공기관 시민 포털·전자서명·나라장터 G2B 연동 기능 보유",
|
||||
"proposal": "GUARDiA 시민포털+GPKI전자서명+나라장터 API 연동 패키지",
|
||||
},
|
||||
"네트워크장비": {
|
||||
"score": 85,
|
||||
"modules": ["네트워크관리", "SNMP", "장애감지", "자동복구"],
|
||||
"reason": "네트워크 장비 SNMP 모니터링·자동 장애복구·에이전트리스 운영",
|
||||
"proposal": "GUARDiA 네트워크관리+에이전트리스SSH+자동복구 패키지",
|
||||
},
|
||||
"소프트웨어개발": {
|
||||
"score": 65,
|
||||
"modules": ["CI/CD", "코드리뷰", "배포자동화", "품질관리"],
|
||||
"reason": "DevOps 파이프라인·배포 자동화·SR 기반 품질 추적 적용",
|
||||
"proposal": "GUARDiA CI/CD자동화+SR연계 코드리뷰+배포추적 패키지",
|
||||
},
|
||||
"시스템통합(SI)": {
|
||||
"score": 72,
|
||||
"modules": ["ITSM", "통합모니터링", "API연동", "멀티테넌트"],
|
||||
"reason": "다수 기관·시스템 통합 프로젝트에 멀티테넌트 GUARDiA 적합",
|
||||
"proposal": "GUARDiA 멀티테넌트+크로스시스템동기화+통합모니터링 패키지",
|
||||
},
|
||||
}
|
||||
|
||||
# 키워드 기반 자동 분류
|
||||
KEYWORD_CATEGORY = {
|
||||
"운영": "IT운영유지보수", "유지보수": "IT운영유지보수", "유지관리": "IT운영유지보수",
|
||||
"보안": "정보보안", "취약점": "정보보안", "침해": "정보보안", "CSAP": "정보보안",
|
||||
"클라우드": "클라우드인프라", "cloud": "클라우드인프라", "서버": "클라우드인프라",
|
||||
"AI": "AI데이터분석", "인공지능": "AI데이터분석", "데이터분석": "AI데이터분석",
|
||||
"전자정부": "전자정부시스템", "민원": "전자정부시스템", "행정": "전자정부시스템",
|
||||
"네트워크": "네트워크장비", "network": "네트워크장비", "라우터": "네트워크장비",
|
||||
"개발": "소프트웨어개발", "시스템개발": "소프트웨어개발",
|
||||
"통합": "시스템통합(SI)", "SI": "시스템통합(SI)", "구축": "시스템통합(SI)",
|
||||
}
|
||||
|
||||
# 캐시 (나라장터 API 응답은 rate-limit 존재)
|
||||
_cache: dict = {"ts": None, "data": []}
|
||||
_CACHE_TTL = 3600 # 1시간
|
||||
|
||||
class G2BProject(BaseModel):
|
||||
bid_no: str
|
||||
title: str
|
||||
org: str
|
||||
budget_krw: Optional[int] = None
|
||||
deadline: Optional[str] = None
|
||||
category: str
|
||||
guardia_score: int
|
||||
guardia_modules: List[str]
|
||||
guardia_reason: str
|
||||
guardia_proposal: str
|
||||
source: str = "나라장터"
|
||||
|
||||
def _classify(title: str) -> str:
|
||||
title_lower = title.lower()
|
||||
for kw, cat in KEYWORD_CATEGORY.items():
|
||||
if kw.lower() in title_lower:
|
||||
return cat
|
||||
return "소프트웨어개발"
|
||||
|
||||
def _make_project_from_api(item: dict) -> G2BProject:
|
||||
title = item.get("bidNtceNm", "") or item.get("sucsfBidAmt", "")
|
||||
category = _classify(title)
|
||||
meta = GUARDIA_CATEGORY_MAP.get(category, GUARDIA_CATEGORY_MAP["소프트웨어개발"])
|
||||
budget_raw = item.get("presmptPrce") or item.get("asignBdgt")
|
||||
try: budget = int(str(budget_raw).replace(",", "")) if budget_raw else None
|
||||
except: budget = None
|
||||
return G2BProject(
|
||||
bid_no=item.get("bidNtceNo", ""),
|
||||
title=title,
|
||||
org=item.get("dminsttNm") or item.get("ntceInsttNm") or "공공기관",
|
||||
budget_krw=budget,
|
||||
deadline=item.get("bidClsedt") or item.get("rsrvtnPrceRnkBidAplyClseDt"),
|
||||
category=category,
|
||||
guardia_score=meta["score"],
|
||||
guardia_modules=meta["modules"],
|
||||
guardia_reason=meta["reason"],
|
||||
guardia_proposal=meta["proposal"],
|
||||
)
|
||||
|
||||
# 샘플 데이터 (폐쇄망/API 키 없을 때)
|
||||
SAMPLE_PROJECTS = [
|
||||
{"bidNtceNo": "G2B-2024-001", "bidNtceNm": "서울시 IT 시스템 운영유지보수 용역", "ntceInsttNm": "서울특별시", "presmptPrce": 500000000, "bidClsedt": "2024-03-31"},
|
||||
{"bidNtceNo": "G2B-2024-002", "bidNtceNm": "경기도 정보보안 취약점 진단 및 보안관제 용역", "ntceInsttNm": "경기도", "presmptPrce": 350000000, "bidClsedt": "2024-04-15"},
|
||||
{"bidNtceNo": "G2B-2024-003", "bidNtceNm": "부산시 클라우드 인프라 구축 및 운영 용역", "ntceInsttNm": "부산광역시", "presmptPrce": 800000000, "bidClsedt": "2024-05-01"},
|
||||
{"bidNtceNo": "G2B-2024-004", "bidNtceNm": "행정안전부 AI 기반 민원 분석 시스템 개발 용역", "ntceInsttNm": "행정안전부", "presmptPrce": 1200000000, "bidClsedt": "2024-04-30"},
|
||||
{"bidNtceNo": "G2B-2024-005", "bidNtceNm": "국가보훈처 전자정부 행정시스템 고도화 용역", "ntceInsttNm": "국가보훈처", "presmptPrce": 600000000, "bidClsedt": "2024-05-15"},
|
||||
{"bidNtceNo": "G2B-2024-006", "bidNtceNm": "인천시 네트워크 장비 유지보수 및 운영 용역", "ntceInsttNm": "인천광역시", "presmptPrce": 250000000, "bidClsedt": "2024-04-20"},
|
||||
{"bidNtceNo": "G2B-2024-007", "bidNtceNm": "기획재정부 정보시스템 통합운영 및 유지보수", "ntceInsttNm": "기획재정부", "presmptPrce": 950000000, "bidClsedt": "2024-06-01"},
|
||||
{"bidNtceNo": "G2B-2024-008", "bidNtceNm": "한국수자원공사 SCADA 보안 시스템 구축", "ntceInsttNm": "한국수자원공사", "presmptPrce": 450000000, "bidClsedt": "2024-04-25"},
|
||||
{"bidNtceNo": "G2B-2024-009", "bidNtceNm": "교육부 클라우드 기반 학습 AI 데이터분석 플랫폼 구축", "ntceInsttNm": "교육부", "presmptPrce": 700000000, "bidClsedt": "2024-05-10"},
|
||||
{"bidNtceNo": "G2B-2024-010", "bidNtceNm": "서울 25개 자치구 통합 IT 시스템 운영관리 용역", "ntceInsttNm": "서울특별시 25개 자치구", "presmptPrce": 2500000000, "bidClsedt": "2024-06-30"},
|
||||
{"bidNtceNo": "G2B-2024-011", "bidNtceNm": "국방부 정보보안 관제 및 취약점 점검 서비스", "ntceInsttNm": "국방부", "presmptPrce": 1800000000, "bidClsedt": "2024-05-20"},
|
||||
{"bidNtceNo": "G2B-2024-012", "bidNtceNm": "경상북도 스마트 행정 시스템 개발 및 구축", "ntceInsttNm": "경상북도", "presmptPrce": 400000000, "bidClsedt": "2024-05-05"},
|
||||
{"bidNtceNo": "G2B-2024-013", "bidNtceNm": "고용노동부 인사행정시스템 통합 운영유지보수", "ntceInsttNm": "고용노동부", "presmptPrce": 320000000, "bidClsedt": "2024-04-10"},
|
||||
{"bidNtceNo": "G2B-2024-014", "bidNtceNm": "전북특별자치도 AI 기반 교통 데이터분석 시스템 구축", "ntceInsttNm": "전북특별자치도", "presmptPrce": 550000000, "bidClsedt": "2024-05-25"},
|
||||
{"bidNtceNo": "G2B-2024-015", "bidNtceNm": "조달청 나라장터 차세대 시스템 개발 용역", "ntceInsttNm": "조달청", "presmptPrce": 5000000000, "bidClsedt": "2024-07-01"},
|
||||
]
|
||||
|
||||
async def _fetch_g2b_api(keyword: str, page: int) -> list:
|
||||
"""나라장터 공공 API 호출 (개방망만)"""
|
||||
params = {
|
||||
"serviceKey": G2B_API_KEY,
|
||||
"pageNo": page,
|
||||
"numOfRows": 30,
|
||||
"type": "json",
|
||||
"bidNtceNmQry": keyword,
|
||||
"srvcTypeCd": "3", # 용역
|
||||
}
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
r = await client.get(f"{G2B_API_BASE}/getScsbidListServcPPSSrch", params=params)
|
||||
if r.status_code == 200:
|
||||
data = r.json()
|
||||
items = (data.get("response") or {}).get("body") or {}
|
||||
return items.get("items") or []
|
||||
except Exception:
|
||||
pass
|
||||
return []
|
||||
|
||||
@router.get("/projects")
|
||||
async def get_g2b_projects(
|
||||
keyword: str = Query(default="IT 운영유지보수", description="검색 키워드"),
|
||||
category: Optional[str] = Query(default=None, description="GUARDiA 분류 필터"),
|
||||
min_score: int = Query(default=0, description="최소 GUARDiA 적합성 점수"),
|
||||
page: int = Query(default=1),
|
||||
limit: int = Query(default=20),
|
||||
):
|
||||
"""나라장터 소프트웨어 개발 용역 목록 + GUARDiA 적합성 분석"""
|
||||
global _cache
|
||||
|
||||
# 캐시 유효성 확인
|
||||
now = datetime.now()
|
||||
use_cache = _cache["ts"] and (now - _cache["ts"]).seconds < _CACHE_TTL and _cache.get("keyword") == keyword
|
||||
|
||||
if not use_cache:
|
||||
raw_items = []
|
||||
if _OPEN and G2B_API_KEY:
|
||||
# 개방망: 실제 나라장터 API 호출
|
||||
raw_items = await _fetch_g2b_api(keyword, page)
|
||||
if not raw_items:
|
||||
# 폐쇄망 또는 API 오류: 샘플 데이터
|
||||
raw_items = SAMPLE_PROJECTS
|
||||
|
||||
projects = [_make_project_from_api(item) for item in raw_items]
|
||||
_cache = {"ts": now, "data": projects, "keyword": keyword}
|
||||
else:
|
||||
projects = _cache["data"]
|
||||
|
||||
# 필터
|
||||
if category:
|
||||
projects = [p for p in projects if p.category == category]
|
||||
if min_score > 0:
|
||||
projects = [p for p in projects if p.guardia_score >= min_score]
|
||||
|
||||
# 점수 내림차순
|
||||
projects = sorted(projects, key=lambda p: p.guardia_score, reverse=True)
|
||||
|
||||
offset = (page - 1) * limit
|
||||
paginated = projects[offset:offset + limit]
|
||||
|
||||
return {
|
||||
"total": len(projects),
|
||||
"page": page,
|
||||
"limit": limit,
|
||||
"keyword": keyword,
|
||||
"source": "나라장터 G2B" + (" (실시간)" if _OPEN and G2B_API_KEY else " (샘플)"),
|
||||
"projects": [p.dict() for p in paginated],
|
||||
}
|
||||
|
||||
@router.get("/categories")
|
||||
async def get_categories():
|
||||
"""GUARDiA 적용 가능 프로젝트 분류 목록"""
|
||||
return {
|
||||
"categories": [
|
||||
{"name": k, "guardia_score": v["score"], "modules": v["modules"]}
|
||||
for k, v in sorted(GUARDIA_CATEGORY_MAP.items(), key=lambda x: -x[1]["score"])
|
||||
]
|
||||
}
|
||||
|
||||
@router.get("/projects/{bid_no}/analysis")
|
||||
async def get_project_analysis(bid_no: str):
|
||||
"""특정 프로젝트 GUARDiA 상세 분석 + Ollama AI 제안서"""
|
||||
project_data = next((p for p in SAMPLE_PROJECTS if p.get("bidNtceNo") == bid_no), None)
|
||||
if not project_data:
|
||||
raise HTTPException(status_code=404, detail="Project not found")
|
||||
|
||||
proj = _make_project_from_api(project_data)
|
||||
|
||||
# Ollama AI 제안서 생성 (온프레미스)
|
||||
proposal_text = proj.guardia_proposal
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=30) as client:
|
||||
r = await client.post("http://localhost:11434/api/generate", json={
|
||||
"model": "llama3",
|
||||
"prompt": f"""다음 공공기관 IT 발주 사업에 GUARDiA AI 운영 플랫폼 적용 방안을 간단히 제안해주세요.
|
||||
사업명: {proj.title}
|
||||
발주기관: {proj.org}
|
||||
예산: {proj.budget_krw:,}원
|
||||
GUARDiA 적합 모듈: {', '.join(proj.guardia_modules)}
|
||||
3문장 이내로 제안해주세요.""",
|
||||
"stream": False,
|
||||
})
|
||||
if r.status_code == 200:
|
||||
proposal_text = r.json().get("response", proposal_text)
|
||||
except Exception:
|
||||
pass # Ollama 미연결 시 기본 제안서 사용
|
||||
|
||||
return {
|
||||
"project": proj.dict(),
|
||||
"ai_proposal": proposal_text,
|
||||
"similar_cases": [
|
||||
"서울시 정보시스템 운영관리 (GUARDiA ITSM 도입, SLA 35% 향상)",
|
||||
"경기도 보안관제센터 (AI-SOC 적용, 탐지율 98%)",
|
||||
],
|
||||
"estimated_roi": {
|
||||
"manpower_reduction": "30%",
|
||||
"sla_improvement": "40%",
|
||||
"incident_response_time": "60% 단축",
|
||||
},
|
||||
}
|
||||
|
||||
@router.get("/summary/by-category")
|
||||
async def get_summary_by_category():
|
||||
"""카테고리별 사업 집계 현황"""
|
||||
projects = [_make_project_from_api(p) for p in SAMPLE_PROJECTS]
|
||||
summary: dict = {}
|
||||
for p in projects:
|
||||
if p.category not in summary:
|
||||
summary[p.category] = {"count": 0, "total_budget": 0, "avg_score": 0, "scores": []}
|
||||
summary[p.category]["count"] += 1
|
||||
summary[p.category]["total_budget"] += (p.budget_krw or 0)
|
||||
summary[p.category]["scores"].append(p.guardia_score)
|
||||
|
||||
result = []
|
||||
for cat, data in sorted(summary.items(), key=lambda x: -x[1]["total_budget"]):
|
||||
result.append({
|
||||
"category": cat,
|
||||
"count": data["count"],
|
||||
"total_budget_krw": data["total_budget"],
|
||||
"avg_guardia_score": round(sum(data["scores"]) / len(data["scores"])),
|
||||
})
|
||||
|
||||
return {"categories": result, "total_projects": len(projects)}
|
||||
|
||||
@router.post("/projects/{bid_no}/create-proposal")
|
||||
async def create_proposal(bid_no: str):
|
||||
"""선택한 사업에 GUARDiA 제안서 SR 자동 생성"""
|
||||
project_data = next((p for p in SAMPLE_PROJECTS if p.get("bidNtceNo") == bid_no), None)
|
||||
if not project_data:
|
||||
raise HTTPException(status_code=404, detail="Project not found")
|
||||
|
||||
proj = _make_project_from_api(project_data)
|
||||
|
||||
# ITSM SR 자동 생성
|
||||
sr_data = {
|
||||
"title": f"[나라장터] {proj.title} - GUARDiA 제안서 작성",
|
||||
"description": (
|
||||
f"발주기관: {proj.org}\n"
|
||||
f"예산: {proj.budget_krw:,}원\n"
|
||||
f"GUARDiA 적합도: {proj.guardia_score}점\n"
|
||||
f"적용 모듈: {', '.join(proj.guardia_modules)}\n"
|
||||
f"제안 내용: {proj.guardia_proposal}"
|
||||
),
|
||||
"priority": "high" if proj.guardia_score >= 90 else "medium",
|
||||
"category": "영업·제안",
|
||||
"tags": ["나라장터", proj.category, "GUARDiA제안"],
|
||||
}
|
||||
|
||||
return {"status": "ok", "message": "제안서 SR이 생성되었습니다", "sr_data": sr_data, "project": proj.dict()}
|
||||
245
routers/infra_native.py
Normal file
245
routers/infra_native.py
Normal file
@ -0,0 +1,245 @@
|
||||
"""
|
||||
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()}
|
||||
171
routers/inventory.py
Normal file
171
routers/inventory.py
Normal file
@ -0,0 +1,171 @@
|
||||
"""
|
||||
부품 재고 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이 생성되었습니다.",
|
||||
}
|
||||
233
routers/mcp_agents.py
Normal file
233
routers/mcp_agents.py
Normal file
@ -0,0 +1,233 @@
|
||||
"""
|
||||
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}
|
||||
123
routers/patches.py
Normal file
123
routers/patches.py
Normal file
@ -0,0 +1,123 @@
|
||||
"""
|
||||
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} 생성됨")
|
||||
202
routers/platform_eng.py
Normal file
202
routers/platform_eng.py
Normal file
@ -0,0 +1,202 @@
|
||||
"""
|
||||
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}
|
||||
202
routers/public_sector2.py
Normal file
202
routers/public_sector2.py
Normal file
@ -0,0 +1,202 @@
|
||||
"""
|
||||
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)}
|
||||
138
routers/search.py
Normal file
138
routers/search.py
Normal file
@ -0,0 +1,138 @@
|
||||
"""
|
||||
통합 검색 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}
|
||||
280
routers/sr_chat.py
Normal file
280
routers/sr_chat.py
Normal file
@ -0,0 +1,280 @@
|
||||
"""
|
||||
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)
|
||||
168
routers/stats.py
Normal file
168
routers/stats.py
Normal file
@ -0,0 +1,168 @@
|
||||
"""
|
||||
통계·보고 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,
|
||||
}
|
||||
92
routers/system.py
Normal file
92
routers/system.py
Normal file
@ -0,0 +1,92 @@
|
||||
"""
|
||||
시스템 정보 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),
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user