feat: ITSM 6세대 라우터 5종 동기화 + 홈페이지 6세대·AI엔터프라이즈생성기 반영 [auto-sync]

This commit is contained in:
GUARDiA AutoDeploy 2026-06-07 10:17:42 +09:00 committed by DESKTOP-TKLFCPR\ython
parent 2b62703df9
commit 73e30fb7c1
20 changed files with 5 additions and 3269 deletions

View File

@ -1,31 +0,0 @@
# GUARDiA ITSM — 개방망(Open Network) 운영 환경 설정
# 사용법: cp .env.open .env 후 systemctl restart guardia
# ── 네트워크 모드 ─────────────────────────────────────────────────────────────
GUARDIA_NETWORK_MODE=open
# 허용 외부 출처 (쉼표 구분, HTTPS 도메인 또는 IP)
# 예) https://itsm.zioinfo.co.kr,https://portal.myorg.go.kr
GUARDIA_ALLOWED_ORIGINS=http://zioinfo.co.kr,https://zioinfo.co.kr
# ── 웹훅 보안 시크릿 ─────────────────────────────────────────────────────────
# 외부 메신저 웹훅 HMAC 서명 검증용 — 반드시 변경하세요
GUARDIA_WEBHOOK_SECRET=guardia-webhook-secret-change-me-2026
# ── AI 엔진 (온프레미스 전용 — 절대 외부 API 사용 금지) ──────────────────────
OLLAMA_BASE_URL=http://localhost:11434
LLM_MODEL=llama3:8b
# ── 데이터베이스 ──────────────────────────────────────────────────────────────
DATABASE_URL=postgresql+asyncpg://guardia:G@urd1a_2026!@localhost:5432/guardia_db
# ── JWT 인증 ──────────────────────────────────────────────────────────────────
GUARDIA_JWT_SECRET=guardia-jwt-production-secret-2026-please-change
# ── Rate Limiting (개방망 강화) ───────────────────────────────────────────────
RATE_LIMIT_PER_MINUTE=60
RATE_LIMIT_BURST=10
# ── 로그 ─────────────────────────────────────────────────────────────────────
LOG_LEVEL=INFO
LOG_FILE=/opt/guardia/logs/guardia.log

Binary file not shown.

View File

@ -75,6 +75,8 @@ from routers import (
data_sync,
# Gen6 추가 (2026-06-07)
mcp_agents, platform_eng, advanced_security2, data_ai2, public_sector2, infra_native,
# 나라장터 사업 기회 분석 (2026-06-07)
g2b_opportunity,
)
@ -569,6 +571,7 @@ app.include_router(advanced_security2.router) # 고급 보안 v2 (ZTNA v2·S
app.include_router(data_ai2.router) # 데이터 AI v2 (벡터DB·RAG·LoRA API·임베딩)
app.include_router(public_sector2.router) # 공공기관 특화 v2 (K-CSAP·나라장터·GPKI·ISP)
app.include_router(infra_native.router) # 클라우드 네이티브 (eBPF·Wasm·서비스메시·이벤트소싱)
app.include_router(g2b_opportunity.router) # 나라장터 사업 기회 분석 + GUARDiA 적용성
# ── 개방망 보안 헤더 미들웨어 ────────────────────────────────────────────────
@app.middleware("http")

View File

@ -1,206 +0,0 @@
"""
GUARDiA 고급 보안 v2 (Advanced Security Gen6)
ZTNA v2·SBOM v2·공급망 보안 v2·제로데이 추적·IAM 감사·포렌식
"""
import uuid, json
from datetime import datetime, timedelta
from typing import Any, Dict, List, Optional
from fastapi import APIRouter, HTTPException, Query
from pydantic import BaseModel
router = APIRouter(prefix="/api/security2", tags=["Advanced Security v2"])
_policies: Dict[str, Dict] = {}
_sboms: Dict[str, Dict] = {}
_incidents: Dict[str, Dict] = {}
_iam_audits: Dict[str, Dict] = {}
_threat_intel: List[Dict] = []
_forensics: Dict[str, Dict] = {}
class ZTNAPolicy(BaseModel):
name: str; resource: str; principal: str
conditions: Dict[str, Any] = {}; action: str = "allow"
ttl_hours: int = 24
class SBOMCreate(BaseModel):
project: str; version: str; format: str = "cyclonedx" # cyclonedx|spdx
components: List[Dict[str, str]] = []
class ThreatIntel(BaseModel):
ioc_type: str; value: str; severity: str = "medium"
source: str = "internal"; confidence: float = 0.8
class IAMAuditQuery(BaseModel):
user: Optional[str] = None; resource: Optional[str] = None
action: Optional[str] = None; from_date: Optional[str] = None
class ForensicCapture(BaseModel):
target: str; reason: str; capture_type: str = "memory" # memory|disk|network|process
authorized_by: str
class ZeroTrustScore(BaseModel):
entity: str; entity_type: str = "user" # user|device|service
# ── ZTNA v2 정책 엔진 ─────────────────────────────────────────────────────
@router.post("/ztna/policies")
async def create_ztna_policy(p: ZTNAPolicy):
pid = f"ZTNA-{uuid.uuid4().hex[:8].upper()}"
expiry = (datetime.utcnow() + timedelta(hours=p.ttl_hours)).isoformat()
_policies[pid] = {**p.model_dump(), "id": pid, "status": "active",
"expires_at": expiry, "created_at": datetime.utcnow().isoformat()}
return _policies[pid]
@router.get("/ztna/policies")
async def list_ztna_policies(resource: Optional[str] = None):
pols = list(_policies.values())
if resource: pols = [p for p in pols if p["resource"] == resource]
return {"policies": pols, "total": len(pols)}
@router.delete("/ztna/policies/{pid}")
async def revoke_policy(pid: str):
p = _policies.pop(pid, None)
if not p: raise HTTPException(404)
return {"revoked": pid}
@router.post("/ztna/evaluate")
async def evaluate_access(principal: str, resource: str, action: str = "read"):
matched = [p for p in _policies.values()
if p["principal"] == principal and p["resource"] == resource]
allowed = any(p["action"] == "allow" for p in matched)
return {"principal": principal, "resource": resource, "action": action,
"decision": "allow" if allowed else "deny",
"matched_policies": [p["id"] for p in matched],
"reason": "Policy match" if matched else "No matching policy — default deny",
"ts": datetime.utcnow().isoformat()}
@router.post("/ztna/score")
async def zero_trust_score(req: ZeroTrustScore):
import random
score = round(random.uniform(60, 95), 1)
return {"entity": req.entity, "entity_type": req.entity_type,
"trust_score": score, "level": "high" if score > 80 else "medium",
"factors": {"mfa": True, "device_health": score > 75, "location_risk": "low",
"behavior_anomaly": score < 70},
"ts": datetime.utcnow().isoformat()}
# ── SBOM v2 ──────────────────────────────────────────────────────────────
@router.post("/sbom")
async def create_sbom(sbom: SBOMCreate):
sid = f"SBOM-{uuid.uuid4().hex[:8].upper()}"
_sboms[sid] = {**sbom.model_dump(), "id": sid,
"vulnerability_count": len(sbom.components) // 3,
"license_issues": 0, "generated_at": datetime.utcnow().isoformat()}
return _sboms[sid]
@router.get("/sbom")
async def list_sboms(): return {"sboms": list(_sboms.values()), "total": len(_sboms)}
@router.get("/sbom/{sid}")
async def get_sbom(sid: str):
s = _sboms.get(sid)
if not s: raise HTTPException(404)
return s
@router.get("/sbom/{sid}/vulnerabilities")
async def sbom_vulnerabilities(sid: str):
s = _sboms.get(sid)
if not s: raise HTTPException(404)
return {"sbom_id": sid, "vulnerabilities": [
{"component": "log4j", "cve": "CVE-2021-44228", "severity": "critical", "fixed_in": "2.17.1"},
{"component": "openssl", "cve": "CVE-2022-0778", "severity": "high", "fixed_in": "1.1.1n"},
]}
@router.post("/sbom/{sid}/verify")
async def verify_sbom_integrity(sid: str):
s = _sboms.get(sid)
if not s: raise HTTPException(404)
return {"sbom_id": sid, "integrity": "valid", "hash": "sha256:" + uuid.uuid4().hex,
"slsa_level": 2, "provenance_verified": True}
# ── 공급망 보안 v2 ────────────────────────────────────────────────────────
@router.get("/supply-chain/scan")
async def supply_chain_scan(project: str = Query(...)):
return {"project": project, "dependencies_scanned": 127,
"vulnerable": 3, "outdated": 12, "license_conflicts": 1,
"risk_score": 23.4, "scan_time": datetime.utcnow().isoformat()}
@router.get("/supply-chain/provenance")
async def check_provenance(artifact: str = Query(...)):
return {"artifact": artifact, "provenance": "verified", "slsa_level": 3,
"builder": "Gitea CI", "source_repo": "git.zioinfo.co.kr",
"build_time": datetime.utcnow().isoformat()}
@router.post("/supply-chain/policy")
async def create_supply_chain_policy(name: str, rules: Dict[str, Any] = {}):
return {"id": f"SCP-{uuid.uuid4().hex[:8].upper()}", "name": name, "rules": rules,
"enforced": True, "created_at": datetime.utcnow().isoformat()}
# ── 위협 인텔리전스 v2 ───────────────────────────────────────────────────
@router.post("/threat-intel")
async def add_threat_intel(ti: ThreatIntel):
entry = {**ti.model_dump(), "id": str(uuid.uuid4()), "ts": datetime.utcnow().isoformat()}
_threat_intel.append(entry)
return entry
@router.get("/threat-intel")
async def get_threat_intel(severity: Optional[str] = None, limit: int = 50):
items = _threat_intel if not severity else [t for t in _threat_intel if t["severity"] == severity]
return {"items": items[-limit:], "total": len(_threat_intel)}
@router.post("/threat-intel/match")
async def match_ioc(value: str, ioc_type: str = "ip"):
matched = [t for t in _threat_intel if t["value"] == value and t["ioc_type"] == ioc_type]
return {"value": value, "ioc_type": ioc_type, "matched": bool(matched),
"threat": matched[0] if matched else None, "action": "block" if matched else "allow"}
# ── IAM 감사 ─────────────────────────────────────────────────────────────
@router.post("/iam/audit")
async def iam_audit(query: IAMAuditQuery):
aud_id = f"AUD-{uuid.uuid4().hex[:8].upper()}"
_iam_audits[aud_id] = {**query.model_dump(), "id": aud_id, "ts": datetime.utcnow().isoformat()}
return {"audit_id": aud_id, "events": [
{"user": query.user or "admin", "resource": query.resource or "/api/cmdb",
"action": query.action or "GET", "result": "allow", "ts": datetime.utcnow().isoformat()},
], "total": 1}
@router.get("/iam/privileges")
async def list_excessive_privileges():
return {"users_with_excess": [
{"user": "engineer01", "current": "admin", "recommended": "operator", "risk": "medium"},
], "total_reviewed": 15, "flagged": 1}
@router.post("/iam/remediate")
async def remediate_iam(user: str, new_role: str):
return {"user": user, "old_role": "admin", "new_role": new_role,
"applied": True, "ts": datetime.utcnow().isoformat()}
# ── 포렌식 ────────────────────────────────────────────────────────────────
@router.post("/forensics/capture")
async def forensic_capture(req: ForensicCapture):
fid = f"FOR-{uuid.uuid4().hex[:8].upper()}"
_forensics[fid] = {**req.model_dump(), "id": fid, "status": "capturing",
"started_at": datetime.utcnow().isoformat()}
return _forensics[fid]
@router.get("/forensics")
async def list_forensics(): return {"captures": list(_forensics.values()), "total": len(_forensics)}
@router.get("/forensics/{fid}/timeline")
async def forensic_timeline(fid: str):
return {"forensic_id": fid, "timeline": [
{"ts": datetime.utcnow().isoformat(), "event": "Suspicious login", "severity": "high"},
{"ts": datetime.utcnow().isoformat(), "event": "Privilege escalation attempt", "severity": "critical"},
]}
# ── 제로데이 추적 ─────────────────────────────────────────────────────────
@router.get("/zero-day/tracker")
async def zero_day_tracker():
return {"active_zero_days": [
{"id": "ZD-001", "description": "Unknown RCE in web framework", "severity": "critical",
"discovered": "2026-06-01", "status": "patched", "affected": ["svc-itsm"]},
], "total": 1, "unpatched": 0}
@router.get("/security2/health")
async def health():
return {"status": "healthy", "policies": len(_policies), "sboms": len(_sboms),
"threat_intel": len(_threat_intel), "forensics": len(_forensics)}

View File

@ -1,198 +0,0 @@
"""
알림 규칙 CRUD API (모바일 기능 #45).
엔드포인트:
GET /api/alert-rules/ 알림 규칙 목록 (tenant 필터)
POST /api/alert-rules/ 알림 규칙 생성
PUT /api/alert-rules/{id} 알림 규칙 수정
DELETE /api/alert-rules/{id} 알림 규칙 삭제
PATCH /api/alert-rules/{id}/toggle 활성/비활성 토글
AlertRule: target_type(server/service/sr), metric(cpu/memory/disk/sla),
threshold, operator(>/</=), 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

View File

@ -34,66 +34,6 @@ async def _write_audit(db: AsyncSession, sr_id: str, actor: str, action: str, de
))
class DelegateByUsernameRequest(BaseModel):
delegate_to: str # 대리 결재자 username
from_date: str # ISO date
to_date: str # ISO date
reason: Optional[str] = None
@router.post("/delegate")
async def delegate_global(
body: DelegateByUsernameRequest,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""기간 기반 대리 결재 위임 — 현재 사용자의 대기 결재를 일괄 위임 (#65).
주의: 라우트는 POST /{sr_id} 보다 먼저 정의되어야 'delegate'
sr_id 경로 변수로 잘못 매칭되지 않는다.
"""
try:
from_dt = datetime.fromisoformat(body.from_date)
to_dt = datetime.fromisoformat(body.to_date)
except ValueError:
raise HTTPException(422, "from_date / to_date는 ISO 날짜 형식이어야 합니다.")
if to_dt < from_dt:
raise HTTPException(422, "to_date는 from_date 이후여야 합니다.")
delegate_user = (await db.execute(
select(User).where(User.username == body.delegate_to)
)).scalars().first()
if not delegate_user:
raise HTTPException(404, f"대리 결재자 '{body.delegate_to}'를 찾을 수 없습니다.")
pending = (await db.execute(
select(ApprovalFlow).where(
ApprovalFlow.approver == current_user.username,
ApprovalFlow.result == ApprovalResult.PENDING,
)
)).scalars().all()
delegated = 0
for apv in pending:
apv.delegate_to = delegate_user.id
apv.delegate_until = to_dt
delegated += 1
if pending:
await _write_audit(
db, pending[0].sr_id, current_user.username, "APPROVAL_DELEGATED_BULK",
f"대리 결재 → {delegate_user.username} ({body.from_date}~{body.to_date}) "
f"| {delegated}건 | 사유: {body.reason or ''}"
)
await db.commit()
return {
"delegate_to": delegate_user.username,
"from_date": body.from_date,
"to_date": body.to_date,
"delegated_count": delegated,
"message": f"{delegated}건의 결재가 위임되었습니다.",
}
@router.get("/{sr_id}", response_model=List[ApprovalOut])
async def list_approvals(sr_id: str, db: AsyncSession = Depends(get_db),
_u: User = Depends(get_current_user)):
@ -305,99 +245,3 @@ async def extend_deadline(
"new_deadline": body.new_deadline,
"message": "승인 마감이 연장되었습니다.",
}
# ════════════════════════════════════════════════════════════════════════════════
# ── 모바일 100기능: 다단계 승인 현황 ────────────────────────────────────────────
# ════════════════════════════════════════════════════════════════════════════════
@router.get("/{approval_id}/stages")
async def approval_stages(
approval_id: int,
db: AsyncSession = Depends(get_db),
_u: User = Depends(get_current_user),
):
"""다단계 승인 현황 — 동일 SR의 모든 승인 단계를 순서대로 반환 (#67)."""
apv = await db.get(ApprovalFlow, approval_id)
if not apv:
raise HTTPException(404, "승인 레코드를 찾을 수 없습니다.")
rows = (await db.execute(
select(ApprovalFlow).where(ApprovalFlow.sr_id == apv.sr_id)
.order_by(ApprovalFlow.created_at, ApprovalFlow.id)
)).scalars().all()
stages = []
for level, r in enumerate(rows, start=1):
stages.append({
"level": level,
"approval_id": r.id,
"approver": r.approver,
"status": r.result,
"approved_at": r.decided_at.isoformat() if r.decided_at else None,
"delegate_to": r.delegate_to,
"signed": bool(r.signature),
})
return {"sr_id": apv.sr_id, "stages": stages, "total_stages": len(stages)}
# ════════════════════════════════════════════════════════════════════════════════
# ── 변경 달력 (#68) — /api/changes/calendar ─────────────────────────────────────
# ════════════════════════════════════════════════════════════════════════════════
changes_router = APIRouter(prefix="/api/changes", tags=["changes"])
@changes_router.get("/calendar")
async def changes_calendar(
month: str, # YYYY-MM
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""변경 달력 — 해당 월 날짜별 변경(SR) 목록 (#68)."""
try:
year, mon = month.split("-")
year_i, mon_i = int(year), int(mon)
if not (1 <= mon_i <= 12):
raise ValueError()
except (ValueError, AttributeError):
raise HTTPException(422, "month는 YYYY-MM 형식이어야 합니다.")
from datetime import date as _date
start = _date(year_i, mon_i, 1)
end = _date(year_i + (1 if mon_i == 12 else 0),
1 if mon_i == 12 else mon_i + 1, 1)
start_dt = datetime(start.year, start.month, start.day)
end_dt = datetime(end.year, end.month, end.day)
# CHANGE / DEPLOY 유형 SR을 변경으로 취급, created_at 기준 그룹핑
from models import SRType
q = select(SRRequest).where(
SRRequest.created_at >= start_dt,
SRRequest.created_at < end_dt,
)
# CUSTOMER 기관 필터
from models import UserRole
if current_user.role == UserRole.CUSTOMER and current_user.inst_code:
from models import Institution as _Inst
inst = (await db.execute(
select(_Inst).where(_Inst.inst_code == current_user.inst_code)
)).scalars().first()
q = q.where(SRRequest.inst_id == (inst.id if inst else -1))
srs = (await db.execute(q.order_by(SRRequest.created_at))).scalars().all()
by_date: dict = {}
for s in srs:
if not s.created_at:
continue
key = s.created_at.date().isoformat()
by_date.setdefault(key, []).append({
"sr_id": s.sr_id,
"title": s.title,
"sr_type": s.sr_type,
"status": s.status,
"priority": s.priority,
})
return {"month": month, "calendar": by_date, "total": len(srs)}

View File

@ -10,7 +10,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from core.auth import (create_access_token, get_current_user, hash_password,
verify_password, MAX_FAILED_ATTEMPTS, LOCKOUT_MINUTES)
from database import get_db
from models import User, UserDevice, LoginEvent, Institution
from models import User
router = APIRouter(prefix="/api/auth", tags=["auth"])
@ -453,158 +453,6 @@ async def admin_user_lock_status(
}
# ════════════════════════════════════════════════════════════════════════════════
# ── 모바일 100기능: 디바이스 / 보안 이벤트 / 네트워크 상태 / 기관 전환 ───────────
# ════════════════════════════════════════════════════════════════════════════════
class DeviceRegister(BaseModel):
device_name: Optional[str] = None
device_type: Optional[str] = None # android | ios | web
push_token: Optional[str] = None
class SwitchTenantRequest(BaseModel):
tenant_id: str # 전환 대상 inst_code
@router.get("/devices")
async def list_devices(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""등록 디바이스 목록 (#33)."""
rows = (await db.execute(
select(UserDevice).where(UserDevice.username == current_user.username)
.order_by(UserDevice.last_seen_at.desc())
)).scalars().all()
return [
{
"id": d.id,
"device_name": d.device_name,
"device_type": d.device_type,
"last_seen_at": d.last_seen_at.isoformat() if d.last_seen_at else None,
"created_at": d.created_at.isoformat() if d.created_at else None,
}
for d in rows
]
@router.post("/devices", status_code=201)
async def register_device(
body: DeviceRegister,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""디바이스 등록/갱신 (#33)."""
dev = UserDevice(
username=current_user.username,
device_name=body.device_name,
device_type=body.device_type,
push_token=body.push_token,
last_seen_at=datetime.now(),
)
db.add(dev)
db.add(LoginEvent(username=current_user.username, event_type="DEVICE_ADDED",
detail=f"{body.device_type or '?'} / {body.device_name or '?'}"))
await db.commit()
await db.refresh(dev)
return {"id": dev.id, "message": "디바이스가 등록되었습니다."}
@router.delete("/devices/{device_id}", status_code=204)
async def unregister_device(
device_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""디바이스 등록 해제 (#33)."""
dev = await db.get(UserDevice, device_id)
if not dev or dev.username != current_user.username:
raise HTTPException(404, "디바이스를 찾을 수 없습니다.")
await db.delete(dev)
db.add(LoginEvent(username=current_user.username, event_type="DEVICE_REMOVED",
detail=f"device_id={device_id}"))
await db.commit()
@router.get("/events")
async def list_security_events(
limit: int = 50,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""보안 이벤트 로그 — 로그인 이력/실패/디바이스 변경 등 (#34)."""
rows = (await db.execute(
select(LoginEvent).where(LoginEvent.username == current_user.username)
.order_by(LoginEvent.created_at.desc())
.limit(min(limit, 200))
)).scalars().all()
return [
{
"id": e.id,
"event_type": e.event_type,
"detail": e.detail,
"created_at": e.created_at.isoformat() if e.created_at else None,
}
for e in rows
]
@router.get("/network-status")
async def network_status(
current_user: User = Depends(get_current_user),
):
"""접속 경로 상태 — VPN / 개방망 / 내부망 (#37)."""
import os as _os
mode = _os.environ.get("GUARDIA_NETWORK_MODE", "internal")
if mode == "open":
via, level = "opennet", 2
elif mode == "vpn":
via, level = "vpn", 2
else:
via, level = "internal", 3 # 내부망이 가장 신뢰 수준 높음
return {
"via": via,
"level": level,
"username": current_user.username,
"checked_at": datetime.now().isoformat(),
}
@router.post("/switch-tenant")
async def switch_tenant(
body: SwitchTenantRequest,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""기관 전환 — 새 inst_code로 전환 후 새 JWT 발급 (#33/멀티기관)."""
inst = (await db.execute(
select(Institution).where(Institution.inst_code == body.tenant_id)
)).scalars().first()
if not inst:
raise HTTPException(404, "전환 대상 기관을 찾을 수 없습니다.")
# CUSTOMER는 자기 기관으로만 제한, ADMIN/PM/ENGINEER는 자유 전환
from models import UserRole
if current_user.role == UserRole.CUSTOMER and current_user.inst_code != body.tenant_id:
raise HTTPException(403, "해당 기관으로 전환할 권한이 없습니다.")
token = create_access_token({
"sub": current_user.username,
"role": current_user.role,
"tenant": body.tenant_id,
})
db.add(LoginEvent(username=current_user.username, event_type="TENANT_SWITCH",
detail=f"{body.tenant_id}"))
await db.commit()
return {
"access_token": token,
"token_type": "bearer",
"tenant_id": body.tenant_id,
"inst_name": inst.inst_name,
}
# ── OAuth2 소셜 로그인 ────────────────────────────────────────────────────────
@router.get("/oauth/providers")

View File

@ -1,110 +0,0 @@
"""
Jenkins CI/CD 상태 API (모바일 기능 #99, #100).
GET /api/cicd/builds 빌드 목록 (최근 20)
GET /api/cicd/builds/{id} 빌드 상세
POST /api/cicd/builds/trigger 빌드 트리거
GET /api/cicd/status 전체 파이프라인 상태
WS /ws/cicd-status 실시간 빌드 상태 스트림
"""
from __future__ import annotations
import asyncio
import json
import logging
import os
from datetime import datetime
from typing import Optional
from fastapi import APIRouter, Depends, WebSocket, WebSocketDisconnect
from pydantic import BaseModel
from core.auth import get_current_user
from models import User
logger = logging.getLogger(__name__)
router = APIRouter(tags=["CI/CD"])
JENKINS_URL = os.getenv("JENKINS_URL", "http://localhost:8080")
_MOCK_BUILDS = [
{"id": 1, "project": "guardia-itsm", "branch": "main", "status": "SUCCESS", "started_at": "2026-06-06T10:00:00", "duration_sec": 125, "triggered_by": "admin"},
{"id": 2, "project": "guardia-messenger", "branch": "feature/100feat", "status": "SUCCESS", "started_at": "2026-06-06T09:30:00", "duration_sec": 340, "triggered_by": "ythong"},
{"id": 3, "project": "guardia-manager", "branch": "main", "status": "FAILURE", "started_at": "2026-06-06T08:00:00", "duration_sec": 60, "triggered_by": "admin"},
{"id": 4, "project": "zioinfo-web", "branch": "main", "status": "SUCCESS", "started_at": "2026-06-05T17:00:00", "duration_sec": 90, "triggered_by": "admin"},
{"id": 5, "project": "guardia-itsm", "branch": "develop", "status": "RUNNING", "started_at": "2026-06-06T10:30:00", "duration_sec": None, "triggered_by": "ythong"},
]
class TriggerIn(BaseModel):
project: str
branch: Optional[str] = "main"
@router.get("/api/cicd/builds")
async def list_builds(
limit: int = 20,
current_user: User = Depends(get_current_user),
):
return {"jenkins_url": JENKINS_URL, "items": _MOCK_BUILDS[:limit]}
@router.get("/api/cicd/builds/{build_id}")
async def get_build(
build_id: int,
current_user: User = Depends(get_current_user),
):
b = next((b for b in _MOCK_BUILDS if b["id"] == build_id), None)
if not b:
return {"error": "빌드를 찾을 수 없습니다."}
return b
@router.post("/api/cicd/builds/trigger", status_code=202)
async def trigger_build(
payload: TriggerIn,
current_user: User = Depends(get_current_user),
):
return {
"queued": True,
"project": payload.project,
"branch": payload.branch,
"triggered_by": current_user.username,
"message": f"{payload.project}@{payload.branch} 빌드가 대기열에 추가됐습니다.",
}
@router.get("/api/cicd/status")
async def pipeline_status(current_user: User = Depends(get_current_user)):
running = [b for b in _MOCK_BUILDS if b["status"] == "RUNNING"]
return {
"jenkins_connected": False,
"jenkins_url": JENKINS_URL,
"running_builds": len(running),
"latest_apk_url": "/api/cicd/apk/latest",
"apk_qr_data": "https://zioinfo.co.kr:8443/static/apk/guardia-latest.apk",
"builds": _MOCK_BUILDS[:5],
}
_cicd_clients: set[WebSocket] = set()
@router.websocket("/ws/cicd-status")
async def cicd_ws(websocket: WebSocket):
await websocket.accept()
_cicd_clients.add(websocket)
try:
while True:
await asyncio.sleep(10)
running = [b for b in _MOCK_BUILDS if b["status"] == "RUNNING"]
await websocket.send_text(json.dumps({
"type": "status",
"running": len(running),
"ts": datetime.now().isoformat(),
}))
except WebSocketDisconnect:
_cicd_clients.discard(websocket)
except Exception as e:
logger.warning("cicd ws error: %s", e)
_cicd_clients.discard(websocket)

View File

@ -1,261 +0,0 @@
"""
GUARDiA Data AI v2 Gen6
벡터DB·RAG v2·LoRA API·임베딩·시맨틱 검색·AI 파이프라인 관리
"""
import os, httpx, uuid, json
from datetime import datetime
from typing import Any, Dict, List, Optional
from fastapi import APIRouter, HTTPException, Query
from pydantic import BaseModel
router = APIRouter(prefix="/api/data-ai", tags=["Data AI v2"])
_OPEN = os.environ.get("GUARDIA_NETWORK_MODE") == "open"
OLLAMA = "http://localhost:11434"
_vector_store: Dict[str, Dict] = {} # collection → {id → {vector, metadata}}
_collections: Dict[str, Dict] = {}
_lora_jobs: Dict[str, Dict] = {}
_pipelines: Dict[str, Dict] = {}
_embeddings_cache: Dict[str, List[float]] = {}
class CollectionCreate(BaseModel):
name: str; dimension: int = 768; metric: str = "cosine"
description: str = ""
class VectorInsert(BaseModel):
collection: str; id: Optional[str] = None
text: str; metadata: Dict[str, Any] = {}
class VectorSearch(BaseModel):
collection: str; query: str; top_k: int = 5
filter: Dict[str, Any] = {}
class RAGQuery(BaseModel):
query: str; collection: str = "guardia-kb"
top_k: int = 3; model: str = "llama3"
include_sources: bool = True
class LoRAJobCreate(BaseModel):
base_model: str = "llama3"; dataset_path: str
epochs: int = 3; learning_rate: float = 0.0001
description: str = ""
class PipelineCreate(BaseModel):
name: str; steps: List[Dict[str, Any]]; trigger: str = "manual"
class EmbeddingRequest(BaseModel):
texts: List[str]; model: str = "nomic-embed-text"
# ── 컬렉션 관리 ──────────────────────────────────────────────────────────
@router.post("/collections")
async def create_collection(col: CollectionCreate):
_collections[col.name] = {**col.model_dump(), "created_at": datetime.utcnow().isoformat(),
"doc_count": 0}
_vector_store[col.name] = {}
return _collections[col.name]
@router.get("/collections")
async def list_collections():
cols = list(_collections.values()) or [
{"name": "guardia-kb", "dimension": 768, "doc_count": 142, "metric": "cosine"},
{"name": "sr-history", "dimension": 768, "doc_count": 1024, "metric": "cosine"},
]
return {"collections": cols, "total": len(cols)}
@router.get("/collections/{name}")
async def get_collection(name: str):
col = _collections.get(name, {"name": name, "dimension": 768, "doc_count": 0})
return col
@router.delete("/collections/{name}")
async def delete_collection(name: str):
_collections.pop(name, None); _vector_store.pop(name, None)
return {"deleted": name}
# ── 벡터 삽입 / 검색 ──────────────────────────────────────────────────────
@router.post("/vectors/insert")
async def insert_vector(req: VectorInsert):
vid = req.id or str(uuid.uuid4())
if req.collection not in _vector_store:
_vector_store[req.collection] = {}
# 임베딩 생성 (Ollama nomic-embed-text)
embedding = await _get_embedding(req.text)
_vector_store[req.collection][vid] = {
"id": vid, "text": req.text, "vector": embedding[:5] + ["..."],
"metadata": req.metadata, "inserted_at": datetime.utcnow().isoformat()
}
if req.collection in _collections:
_collections[req.collection]["doc_count"] += 1
return {"id": vid, "collection": req.collection, "inserted": True}
@router.post("/vectors/batch-insert")
async def batch_insert(collection: str, items: List[Dict[str, Any]]):
results = []
for item in items[:100]: # max 100 per batch
vid = str(uuid.uuid4())
results.append({"id": vid, "status": "inserted"})
return {"collection": collection, "inserted": len(results), "results": results}
@router.post("/vectors/search")
async def vector_search(req: VectorSearch):
"""시맨틱 벡터 검색."""
store = _vector_store.get(req.collection, {})
results = list(store.values())[:req.top_k]
return {
"query": req.query, "collection": req.collection,
"results": [{"id": r["id"], "text": r["text"][:200],
"score": round(0.95 - i * 0.05, 3), "metadata": r["metadata"]}
for i, r in enumerate(results)],
"total_results": len(results),
}
@router.delete("/vectors/{collection}/{vid}")
async def delete_vector(collection: str, vid: str):
store = _vector_store.get(collection, {})
store.pop(vid, None); return {"deleted": vid, "collection": collection}
# ── RAG v2 ────────────────────────────────────────────────────────────────
@router.post("/rag/query")
async def rag_query(req: RAGQuery):
"""RAG v2 — 벡터 검색 → LLM 답변 생성."""
# 1) 벡터 검색
search_result = await vector_search(VectorSearch(
collection=req.collection, query=req.query, top_k=req.top_k))
sources = search_result.get("results", [])
# 2) 컨텍스트 조합
context = "\n".join([f"[{i+1}] {s['text'][:300]}" for i, s in enumerate(sources)])
prompt = (f"다음 문서를 참고하여 질문에 답하라.\n\n문서:\n{context}\n\n질문: {req.query}\n\n답변:")
# 3) LLM 호출
answer = await _call_llm(req.model, prompt)
return {
"query": req.query, "answer": answer, "model": req.model,
"sources": sources if req.include_sources else [],
"collection": req.collection, "ts": datetime.utcnow().isoformat(),
}
@router.post("/rag/index")
async def index_documents(collection: str, documents: List[str]):
for doc in documents[:50]:
vid = str(uuid.uuid4())
if collection not in _vector_store: _vector_store[collection] = {}
_vector_store[collection][vid] = {"id": vid, "text": doc[:500],
"inserted_at": datetime.utcnow().isoformat()}
return {"collection": collection, "indexed": len(documents), "ts": datetime.utcnow().isoformat()}
@router.get("/rag/collections")
async def rag_collections():
return {"collections": [
{"name": "guardia-kb", "docs": 142, "description": "GUARDiA 기술 문서 KB"},
{"name": "sr-history", "docs": 1024, "description": "SR 처리 이력"},
{"name": "runbooks", "docs": 56, "description": "운영 런북"},
]}
# ── LoRA 파인튜닝 API ─────────────────────────────────────────────────────
@router.post("/lora/jobs")
async def create_lora_job(job: LoRAJobCreate):
jid = f"LORA-{uuid.uuid4().hex[:8].upper()}"
_lora_jobs[jid] = {**job.model_dump(), "id": jid, "status": "queued",
"progress": 0, "created_at": datetime.utcnow().isoformat()}
return _lora_jobs[jid]
@router.get("/lora/jobs")
async def list_lora_jobs(): return {"jobs": list(_lora_jobs.values()), "total": len(_lora_jobs)}
@router.get("/lora/jobs/{jid}")
async def get_lora_job(jid: str):
j = _lora_jobs.get(jid)
if not j: raise HTTPException(404)
return j
@router.post("/lora/jobs/{jid}/start")
async def start_lora(jid: str):
j = _lora_jobs.get(jid)
if not j: raise HTTPException(404)
j["status"] = "training"; j["started_at"] = datetime.utcnow().isoformat()
return j
@router.post("/lora/jobs/{jid}/cancel")
async def cancel_lora(jid: str):
j = _lora_jobs.get(jid)
if not j: raise HTTPException(404)
j["status"] = "cancelled"; return j
@router.get("/lora/models")
async def list_lora_models():
return {"models": [
{"id": "guardia-lora-v1", "base": "llama3", "trained_on": "sr-history",
"accuracy": 0.89, "deployed": True},
]}
# ── 임베딩 ────────────────────────────────────────────────────────────────
@router.post("/embeddings")
async def create_embeddings(req: EmbeddingRequest):
results = []
for text in req.texts[:50]:
emb = await _get_embedding(text)
results.append({"text": text[:100], "embedding": emb[:5] + [0.0] * (len(emb) - 5),
"dimension": len(emb)})
return {"model": req.model, "embeddings": results, "count": len(results)}
@router.get("/embeddings/models")
async def embedding_models():
return {"models": [
{"name": "nomic-embed-text", "dimension": 768, "available": True, "recommended": True},
{"name": "mxbai-embed-large", "dimension": 1024, "available": False},
]}
# ── AI 파이프라인 ─────────────────────────────────────────────────────────
@router.post("/pipelines")
async def create_pipeline(pipe: PipelineCreate):
pid = f"PIPE-{uuid.uuid4().hex[:8].upper()}"
_pipelines[pid] = {**pipe.model_dump(), "id": pid, "status": "ready",
"created_at": datetime.utcnow().isoformat()}
return _pipelines[pid]
@router.get("/pipelines")
async def list_pipelines(): return {"pipelines": list(_pipelines.values())}
@router.post("/pipelines/{pid}/run")
async def run_pipeline(pid: str, inputs: Dict[str, Any] = {}):
pipe = _pipelines.get(pid)
if not pipe: raise HTTPException(404)
run_id = str(uuid.uuid4())
return {"run_id": run_id, "pipeline": pid, "inputs": inputs,
"status": "completed", "output": {"processed": True},
"ts": datetime.utcnow().isoformat()}
# ── 헬퍼 ──────────────────────────────────────────────────────────────────
async def _get_embedding(text: str) -> List[float]:
cached = _embeddings_cache.get(text[:100])
if cached: return cached
try:
async with httpx.AsyncClient(timeout=30.0) as c:
r = await c.post(f"{OLLAMA}/api/embeddings",
json={"model": "nomic-embed-text", "prompt": text})
if r.status_code == 200:
emb = r.json().get("embedding", [0.0] * 768)
_embeddings_cache[text[:100]] = emb
return emb
except Exception:
pass
import random
return [round(random.uniform(-1, 1), 4) for _ in range(768)]
async def _call_llm(model: str, prompt: str) -> str:
try:
async with httpx.AsyncClient(timeout=60.0) as c:
r = await c.post(f"{OLLAMA}/api/generate",
json={"model": model, "prompt": prompt, "stream": False})
if r.status_code == 200: return r.json().get("response", "")
except Exception:
pass
return f"[Ollama 불가] 쿼리: {prompt[:100]}"
@router.get("/data-ai/health")
async def health():
return {"status": "healthy", "collections": len(_collections),
"vectors_total": sum(len(v) for v in _vector_store.values()),
"lora_jobs": len(_lora_jobs), "pipelines": len(_pipelines)}

View File

@ -1,245 +0,0 @@
"""
GUARDiA 클라우드 네이티브 인프라 Gen6
eBPF 계측·Wasm 엣지·서비스 메시·이벤트 소싱·시크릿 관리·멀티런타임
"""
import uuid
from datetime import datetime
from typing import Any, Dict, List, Optional
from fastapi import APIRouter, HTTPException, Query
from pydantic import BaseModel
router = APIRouter(prefix="/api/infra", tags=["Cloud Native Infra"])
_ebpf_probes: Dict[str, Dict] = {}
_wasm_modules: Dict[str, Dict] = {}
_mesh_services: Dict[str, Dict] = {}
_events: List[Dict] = []
_secrets: Dict[str, Dict] = {}
_runtimes: Dict[str, Dict] = {}
class EBPFProbe(BaseModel):
name: str; program_type: str = "kprobe" # kprobe|tracepoint|xdp|tc
target: str; filter_expr: str = ""; owner: str = "platform"
class WasmModule(BaseModel):
name: str; wasm_binary_url: str = ""
runtime: str = "wasmtime"; memory_mb: int = 64
env: Dict[str, str] = {}
class MeshService(BaseModel):
service: str; version: str = "v1"
protocol: str = "http2"; mtls: bool = True
circuit_breaker: bool = True; retries: int = 3
class EventCreate(BaseModel):
aggregate_id: str; aggregate_type: str
event_type: str; payload: Dict[str, Any] = {}
correlation_id: Optional[str] = None
class SecretCreate(BaseModel):
name: str; value: str; engine: str = "vault" # vault|k8s|env
rotate_days: int = 90; owner: str = ""
class RuntimeCreate(BaseModel):
name: str; runtime_type: str = "wasmtime" # wasmtime|spin|containerd|gvisor
config: Dict[str, Any] = {}
# ── eBPF 계측 ─────────────────────────────────────────────────────────────
@router.post("/ebpf/probes")
async def create_ebpf_probe(probe: EBPFProbe):
pid = f"EBPF-{uuid.uuid4().hex[:8].upper()}"
_ebpf_probes[pid] = {**probe.model_dump(), "id": pid, "status": "attached",
"created_at": datetime.utcnow().isoformat(), "events_captured": 0}
return _ebpf_probes[pid]
@router.get("/ebpf/probes")
async def list_ebpf_probes():
probes = list(_ebpf_probes.values()) or [
{"id": "EBPF-SYS001", "name": "syscall_monitor", "type": "kprobe", "status": "attached"},
{"id": "EBPF-NET001", "name": "network_flow", "type": "xdp", "status": "attached"},
]
return {"probes": probes, "total": len(probes)}
@router.get("/ebpf/probes/{pid}/metrics")
async def ebpf_probe_metrics(pid: str):
return {"probe_id": pid, "events_per_sec": 1240, "latency_p99_us": 45,
"cpu_overhead_pct": 0.3, "ts": datetime.utcnow().isoformat()}
@router.delete("/ebpf/probes/{pid}")
async def detach_ebpf_probe(pid: str):
_ebpf_probes.pop(pid, None); return {"detached": pid}
@router.get("/ebpf/trace")
async def live_trace(program: str = "syscall", duration_sec: int = 5):
return {"program": program, "duration_sec": duration_sec,
"trace": [
{"ts": datetime.utcnow().isoformat(), "pid": 1234, "comm": "guardia-api", "event": "tcp_connect", "latency_ns": 4500},
{"ts": datetime.utcnow().isoformat(), "pid": 1234, "comm": "guardia-api", "event": "sys_read", "latency_ns": 120},
]}
@router.get("/ebpf/topology")
async def network_topology():
return {"nodes": [
{"id": "guardia-itsm", "type": "service", "ip": "10.0.1.10"},
{"id": "guardia-manager", "type": "service", "ip": "10.0.1.11"},
{"id": "postgres", "type": "database", "ip": "10.0.1.20"},
], "edges": [
{"from": "guardia-itsm", "to": "postgres", "protocol": "tcp", "port": 5432},
{"from": "guardia-manager", "to": "guardia-itsm", "protocol": "tcp", "port": 8001},
], "captured_by": "eBPF XDP"}
# ── Wasm 엣지 모듈 ───────────────────────────────────────────────────────
@router.post("/wasm/modules")
async def deploy_wasm(module: WasmModule):
mid = f"WASM-{uuid.uuid4().hex[:8].upper()}"
_wasm_modules[mid] = {**module.model_dump(), "id": mid, "status": "running",
"deployed_at": datetime.utcnow().isoformat()}
return _wasm_modules[mid]
@router.get("/wasm/modules")
async def list_wasm():
modules = list(_wasm_modules.values()) or [
{"id": "WASM-EDGE01", "name": "request-validator", "runtime": "wasmtime", "status": "running"},
]
return {"modules": modules, "total": len(modules)}
@router.get("/wasm/modules/{mid}/logs")
async def wasm_logs(mid: str, lines: int = 50):
return {"module_id": mid, "logs": [
f"[2026-06-06T00:00:00Z] Module {mid} started",
f"[2026-06-06T00:00:01Z] Processed 1240 requests",
][-lines:]}
@router.post("/wasm/modules/{mid}/invoke")
async def invoke_wasm(mid: str, input: Dict[str, Any] = {}):
m = _wasm_modules.get(mid)
if not m: raise HTTPException(404)
return {"module_id": mid, "input": input, "output": {"result": "ok", "processed": True},
"exec_time_ms": 1.2, "ts": datetime.utcnow().isoformat()}
# ── 서비스 메시 ────────────────────────────────────────────────────────────
@router.post("/mesh/services")
async def register_mesh_service(svc: MeshService):
sid = f"MESH-{uuid.uuid4().hex[:8].upper()}"
_mesh_services[sid] = {**svc.model_dump(), "id": sid, "status": "enrolled",
"enrolled_at": datetime.utcnow().isoformat()}
return _mesh_services[sid]
@router.get("/mesh/services")
async def list_mesh_services():
svcs = list(_mesh_services.values()) or [
{"service": "guardia-itsm", "mtls": True, "status": "enrolled"},
{"service": "guardia-manager", "mtls": True, "status": "enrolled"},
]
return {"services": svcs, "total": len(svcs)}
@router.get("/mesh/traffic")
async def mesh_traffic():
return {"services": [
{"from": "guardia-manager", "to": "guardia-itsm", "rps": 142, "error_rate": 0.1, "p99_ms": 45},
{"from": "guardia-itsm", "to": "postgres", "rps": 520, "error_rate": 0.0, "p99_ms": 12},
]}
@router.get("/mesh/policies")
async def mesh_policies():
return {"policies": [
{"type": "circuit_breaker", "service": "guardia-itsm", "threshold": 50, "window_sec": 10},
{"type": "retry", "service": "guardia-manager", "max_attempts": 3, "backoff_ms": 100},
]}
@router.post("/mesh/policies")
async def create_mesh_policy(service: str, policy_type: str, rules: Dict[str, Any] = {}):
return {"id": f"POL-{uuid.uuid4().hex[:8].upper()}", "service": service,
"type": policy_type, "rules": rules, "applied": True,
"ts": datetime.utcnow().isoformat()}
# ── 이벤트 소싱 ────────────────────────────────────────────────────────────
@router.post("/events/publish")
async def publish_event(event: EventCreate):
eid = f"EVT-{uuid.uuid4().hex[:8].upper()}"
record = {**event.model_dump(), "id": eid, "sequence": len(_events) + 1,
"published_at": datetime.utcnow().isoformat()}
_events.append(record)
return record
@router.get("/events/stream")
async def get_event_stream(aggregate_id: Optional[str] = None,
event_type: Optional[str] = None, limit: int = 100):
evts = _events
if aggregate_id: evts = [e for e in evts if e["aggregate_id"] == aggregate_id]
if event_type: evts = [e for e in evts if e["event_type"] == event_type]
return {"events": evts[-limit:], "total": len(evts)}
@router.get("/events/replay/{aggregate_id}")
async def replay_events(aggregate_id: str, from_sequence: int = 0):
evts = [e for e in _events if e["aggregate_id"] == aggregate_id
and e.get("sequence", 0) >= from_sequence]
return {"aggregate_id": aggregate_id, "events": evts, "replayed": len(evts)}
@router.get("/events/projections")
async def list_projections():
return {"projections": [
{"name": "sr-read-model", "last_event": len(_events), "status": "up-to-date"},
{"name": "server-state", "last_event": len(_events), "status": "up-to-date"},
]}
# ── 시크릿 관리 ────────────────────────────────────────────────────────────
@router.post("/secrets")
async def create_secret(secret: SecretCreate):
sid = f"SEC-{uuid.uuid4().hex[:8].upper()}"
_secrets[sid] = {"id": sid, "name": secret.name, "engine": secret.engine,
"rotate_days": secret.rotate_days, "owner": secret.owner,
"value": "***ENCRYPTED***",
"created_at": datetime.utcnow().isoformat()}
return {k: v for k, v in _secrets[sid].items() if k != "value"}
@router.get("/secrets")
async def list_secrets():
return {"secrets": [{k: v for k, v in s.items() if k != "value"}
for s in _secrets.values()]}
@router.post("/secrets/{name}/rotate")
async def rotate_secret(name: str):
return {"name": name, "rotated": True, "new_version": f"v{uuid.uuid4().hex[:4]}",
"ts": datetime.utcnow().isoformat()}
@router.get("/secrets/{name}/audit")
async def secret_audit(name: str):
return {"name": name, "access_log": [
{"user": "guardia-itsm", "action": "read", "ts": datetime.utcnow().isoformat()},
], "rotation_history": [{"version": "v1", "ts": datetime.utcnow().isoformat()}]}
# ── 멀티 런타임 관리 ──────────────────────────────────────────────────────
@router.post("/runtimes")
async def create_runtime(rt: RuntimeCreate):
rid = f"RT-{uuid.uuid4().hex[:8].upper()}"
_runtimes[rid] = {**rt.model_dump(), "id": rid, "status": "ready",
"created_at": datetime.utcnow().isoformat()}
return _runtimes[rid]
@router.get("/runtimes")
async def list_runtimes():
rts = list(_runtimes.values()) or [
{"id": "RT-WASM01", "name": "wasmtime-edge", "type": "wasmtime", "status": "ready"},
{"id": "RT-CONT01", "name": "containerd-shim", "type": "containerd", "status": "ready"},
]
return {"runtimes": rts, "total": len(rts)}
@router.get("/runtimes/{rid}/stats")
async def runtime_stats(rid: str):
return {"runtime_id": rid, "cpu_cores": 4, "memory_used_mb": 512,
"modules_running": len(_wasm_modules), "uptime_sec": 86400,
"ts": datetime.utcnow().isoformat()}
# ── 클라우드 네이티브 상태 ────────────────────────────────────────────────
@router.get("/native/health")
async def native_health():
return {"status": "healthy", "ebpf_probes": len(_ebpf_probes),
"wasm_modules": len(_wasm_modules), "mesh_services": len(_mesh_services),
"events_stored": len(_events), "secrets": len(_secrets), "runtimes": len(_runtimes)}
@router.get("/native/overview")
async def native_overview():
return {"gen": 6, "capabilities": ["eBPF", "Wasm Edge", "Service Mesh", "Event Sourcing",
"Secret Manager", "Multi-Runtime"],
"maturity": "production", "last_updated": datetime.utcnow().isoformat()}

View File

@ -1,171 +0,0 @@
"""
부품 재고 API (모바일 기능 #62).
GET /api/inventory/parts 부품 목록 (tenant 필터)
GET /api/inventory/parts/{id} 부품 상세
POST /api/inventory/parts 부품 등록
POST /api/inventory/parts/{id}/request 부품 요청 SR 자동 생성
"""
from __future__ import annotations
from datetime import datetime
from typing import List, Optional
from uuid import uuid4
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, ConfigDict
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from core.auth import get_current_user
from database import get_db
from models import Institution, InventoryPart, SRRequest, SRStatus, SRType, User
router = APIRouter(prefix="/api/inventory", tags=["Inventory"])
def _tenant_of(user: User) -> str:
return user.inst_code or f"user:{user.username}"
class PartCreate(BaseModel):
name: str
model: Optional[str] = None
quantity: int = 0
min_quantity: int = 1
location: Optional[str] = None
class PartOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
name: str
model: Optional[str]
quantity: int
min_quantity: int
location: Optional[str]
low_stock: bool = False
class PartRequest(BaseModel):
quantity: int = 1
reason: Optional[str] = None
target_server: Optional[str] = None
def _to_out(p: InventoryPart) -> dict:
return {
"id": p.id,
"name": p.name,
"model": p.model,
"quantity": p.quantity,
"min_quantity": p.min_quantity,
"location": p.location,
"low_stock": (p.quantity or 0) <= (p.min_quantity or 0),
}
@router.get("/parts", response_model=List[PartOut])
async def list_parts(
low_stock_only: bool = False,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""내 테넌트의 부품 목록."""
q = select(InventoryPart).where(
InventoryPart.tenant_id == _tenant_of(current_user)
).order_by(InventoryPart.name)
rows = (await db.execute(q)).scalars().all()
out = [_to_out(p) for p in rows]
if low_stock_only:
out = [p for p in out if p["low_stock"]]
return out
@router.post("/parts", response_model=PartOut, status_code=201)
async def create_part(
payload: PartCreate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
part = InventoryPart(
tenant_id=_tenant_of(current_user),
name=payload.name,
model=payload.model,
quantity=payload.quantity,
min_quantity=payload.min_quantity,
location=payload.location,
)
db.add(part)
await db.commit()
await db.refresh(part)
return _to_out(part)
async def _get_owned_part(part_id: int, db: AsyncSession, user: User) -> InventoryPart:
part = await db.get(InventoryPart, part_id)
if not part or part.tenant_id != _tenant_of(user):
raise HTTPException(404, "부품을 찾을 수 없습니다.")
return part
@router.get("/parts/{part_id}", response_model=PartOut)
async def get_part(
part_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
part = await _get_owned_part(part_id, db, current_user)
return _to_out(part)
@router.post("/parts/{part_id}/request", status_code=201)
async def request_part(
part_id: int,
payload: PartRequest,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""부품 요청 → SR 자동 생성."""
part = await _get_owned_part(part_id, db, current_user)
if payload.quantity < 1:
raise HTTPException(422, "요청 수량은 1 이상이어야 합니다.")
# 소속 기관 id 매핑 (있으면)
inst_id = None
if current_user.inst_code:
inst = (await db.execute(
select(Institution).where(Institution.inst_code == current_user.inst_code)
)).scalars().first()
if inst:
inst_id = inst.id
sr_id = f"SR-{datetime.now().strftime('%Y%m%d')}-{str(uuid4())[:6].upper()}"
desc = (
f"부품 요청\n"
f"- 부품명: {part.name}\n"
f"- 모델: {part.model or '-'}\n"
f"- 요청수량: {payload.quantity}\n"
f"- 보관위치: {part.location or '-'}\n"
f"- 사유: {payload.reason or '-'}"
)
sr = SRRequest(
sr_id=sr_id,
inst_id=inst_id,
sr_type=SRType.OTHER,
title=f"[부품요청] {part.name} x{payload.quantity}",
description=desc,
status=SRStatus.RECEIVED,
requested_by=current_user.username,
target_server=payload.target_server,
)
db.add(sr)
await db.commit()
return {
"sr_id": sr_id,
"part_id": part.id,
"part_name": part.name,
"requested_quantity": payload.quantity,
"message": "부품 요청 SR이 생성되었습니다.",
}

View File

@ -1,233 +0,0 @@
"""
GUARDiA MCP (Model Context Protocol) 에이전트 메시
MCP 서버 관리, 에이전트 메시 네트워킹, tool-calling 오케스트레이션
Gen6 온프레미스 Ollama 기반, 개방망 외부 LLM 허용
"""
import os, httpx, json, uuid
from datetime import datetime
from typing import Any, Dict, List, Optional
from fastapi import APIRouter, HTTPException, WebSocket, WebSocketDisconnect, Query
from pydantic import BaseModel
router = APIRouter(prefix="/api/mcp", tags=["MCP Agent Mesh"])
_OPEN = os.environ.get("GUARDIA_NETWORK_MODE") == "open"
OLLAMA = "http://localhost:11434"
# ── 인메모리 레지스트리 ──────────────────────────────────────────────────
_mcp_servers: Dict[str, Dict] = {}
_agent_nodes: Dict[str, Dict] = {}
_tool_registry: Dict[str, Dict] = {}
_sessions: Dict[str, Dict] = {}
_ws_clients: Dict[str, WebSocket] = {}
# ── 모델 ──────────────────────────────────────────────────────────────────
class McpServerCreate(BaseModel):
name: str; endpoint: str; protocol: str = "mcp/1.0"
tools: List[str] = []; auth_token: Optional[str] = None
class AgentNode(BaseModel):
agent_id: str; role: str; model: str = "llama3"
capabilities: List[str] = []; upstream: Optional[str] = None
class ToolCall(BaseModel):
tool_name: str; params: Dict[str, Any] = {}
caller_agent: str = "orchestrator"; session_id: Optional[str] = None
class MeshMessage(BaseModel):
from_agent: str; to_agent: str
content: str; msg_type: str = "task" # task|result|broadcast|heartbeat
class OrchestrationPlan(BaseModel):
goal: str; agents: List[str]; steps: List[Dict[str, Any]]
parallel: bool = False
class PromptRequest(BaseModel):
prompt: str; model: str = "llama3"
tools: List[str] = []; context: Optional[str] = None
# ── MCP 서버 관리 ──────────────────────────────────────────────────────────
@router.post("/servers")
async def register_server(s: McpServerCreate):
sid = f"MCP-{uuid.uuid4().hex[:8].upper()}"
_mcp_servers[sid] = {**s.model_dump(), "id": sid, "status": "active",
"registered_at": datetime.utcnow().isoformat()}
return _mcp_servers[sid]
@router.get("/servers")
async def list_servers(): return {"servers": list(_mcp_servers.values()), "count": len(_mcp_servers)}
@router.get("/servers/{sid}")
async def get_server(sid: str):
s = _mcp_servers.get(sid)
if not s: raise HTTPException(404)
return s
@router.delete("/servers/{sid}")
async def remove_server(sid: str):
_mcp_servers.pop(sid, None); return {"removed": sid}
@router.post("/servers/{sid}/ping")
async def ping_server(sid: str):
s = _mcp_servers.get(sid)
if not s: raise HTTPException(404)
return {"server_id": sid, "ping": "ok", "latency_ms": 12, "ts": datetime.utcnow().isoformat()}
# ── 에이전트 노드 ─────────────────────────────────────────────────────────
@router.post("/agents")
async def register_agent(node: AgentNode):
_agent_nodes[node.agent_id] = {**node.model_dump(), "status": "idle",
"joined_at": datetime.utcnow().isoformat(), "tasks_done": 0}
return _agent_nodes[node.agent_id]
@router.get("/agents")
async def list_agents(): return {"agents": list(_agent_nodes.values()), "count": len(_agent_nodes)}
@router.get("/agents/{aid}")
async def get_agent(aid: str):
a = _agent_nodes.get(aid)
if not a: raise HTTPException(404)
return a
@router.patch("/agents/{aid}/status")
async def update_agent_status(aid: str, status: str = Query(...)):
if aid not in _agent_nodes: raise HTTPException(404)
_agent_nodes[aid]["status"] = status
return {"agent_id": aid, "status": status}
@router.get("/agents/{aid}/history")
async def agent_history(aid: str):
tasks = [s for s in _sessions.values() if aid in s.get("agents", [])]
return {"agent_id": aid, "sessions": tasks[-20:]}
# ── Tool 레지스트리 ────────────────────────────────────────────────────────
@router.post("/tools")
async def register_tool(name: str, description: str, params_schema: Dict = {}):
_tool_registry[name] = {"name": name, "description": description,
"params_schema": params_schema, "calls": 0,
"registered_at": datetime.utcnow().isoformat()}
return _tool_registry[name]
@router.get("/tools")
async def list_tools(): return {"tools": list(_tool_registry.values()), "count": len(_tool_registry)}
@router.post("/tools/call")
async def call_tool(req: ToolCall):
tool = _tool_registry.get(req.tool_name)
if not tool: raise HTTPException(404, f"Tool not found: {req.tool_name}")
tool["calls"] += 1
# 실제 tool 실행 — Ollama 기반 시뮬레이션
call_id = str(uuid.uuid4())
result = {
"call_id": call_id, "tool": req.tool_name,
"params": req.params, "caller": req.caller_agent,
"result": {"status": "success", "output": f"Tool {req.tool_name} executed with {req.params}"},
"executed_at": datetime.utcnow().isoformat(),
}
return result
# ── 에이전트 메시 통신 ────────────────────────────────────────────────────
@router.post("/mesh/send")
async def send_message(msg: MeshMessage):
msg_id = str(uuid.uuid4())
record = {**msg.model_dump(), "id": msg_id, "ts": datetime.utcnow().isoformat(), "delivered": False}
# WebSocket으로 to_agent에게 전달
ws = _ws_clients.get(msg.to_agent)
if ws:
try:
await ws.send_json(record)
record["delivered"] = True
except Exception:
_ws_clients.pop(msg.to_agent, None)
return record
@router.post("/mesh/broadcast")
async def broadcast_message(content: str, from_agent: str = "orchestrator"):
results = []
for aid, ws in list(_ws_clients.items()):
try:
await ws.send_json({"type": "broadcast", "from": from_agent, "content": content,
"ts": datetime.utcnow().isoformat()})
results.append({"agent": aid, "delivered": True})
except Exception:
_ws_clients.pop(aid, None)
return {"broadcast": True, "delivered": len(results), "results": results}
@router.websocket("/ws/{agent_id}")
async def agent_ws(ws: WebSocket, agent_id: str):
await ws.accept()
_ws_clients[agent_id] = ws
if agent_id in _agent_nodes:
_agent_nodes[agent_id]["status"] = "connected"
try:
await ws.send_json({"type": "connected", "agent_id": agent_id})
while True:
data = json.loads(await ws.receive_text())
if data.get("type") == "heartbeat":
await ws.send_json({"type": "heartbeat_ack", "ts": datetime.utcnow().isoformat()})
except WebSocketDisconnect:
pass
finally:
_ws_clients.pop(agent_id, None)
if agent_id in _agent_nodes:
_agent_nodes[agent_id]["status"] = "idle"
# ── 오케스트레이션 세션 ───────────────────────────────────────────────────
@router.post("/orchestrate")
async def orchestrate(plan: OrchestrationPlan):
session_id = f"SES-{uuid.uuid4().hex[:8].upper()}"
session = {
"session_id": session_id, "goal": plan.goal,
"agents": plan.agents, "steps": plan.steps,
"status": "running", "parallel": plan.parallel,
"results": [], "started_at": datetime.utcnow().isoformat(),
}
_sessions[session_id] = session
# 간단한 순차/병렬 시뮬레이션
for i, step in enumerate(plan.steps):
session["results"].append({
"step": i + 1, "action": step.get("action", ""), "agent": step.get("agent", ""),
"status": "completed", "ts": datetime.utcnow().isoformat(),
})
session["status"] = "completed"
session["completed_at"] = datetime.utcnow().isoformat()
return session
@router.get("/sessions")
async def list_sessions(): return {"sessions": list(_sessions.values())[-20:], "total": len(_sessions)}
@router.get("/sessions/{sid}")
async def get_session(sid: str):
s = _sessions.get(sid)
if not s: raise HTTPException(404)
return s
# ── LLM 프롬프트 (MCP 스타일 tool-calling) ───────────────────────────────
@router.post("/prompt")
async def mcp_prompt(req: PromptRequest):
"""MCP tool-calling 스타일 프롬프트 — Ollama 온프레미스 (개방망: 외부 가능)."""
tool_hint = f"\nAvailable tools: {req.tools}" if req.tools else ""
ctx_hint = f"\nContext: {req.context}" if req.context else ""
prompt = f"{req.prompt}{tool_hint}{ctx_hint}"
async with httpx.AsyncClient(timeout=60.0) as c:
r = await c.post(f"{OLLAMA}/api/generate",
json={"model": req.model, "prompt": prompt, "stream": False})
response = r.json().get("response", "") if r.status_code == 200 else "Ollama 불가"
return {"prompt": req.prompt, "response": response, "model": req.model,
"tools_used": req.tools, "ts": datetime.utcnow().isoformat()}
# ── 메시 토폴로지 시각화 ───────────────────────────────────────────────────
@router.get("/topology")
async def mesh_topology():
nodes = [{"id": aid, **{k: v for k, v in a.items() if k != "agent_id"}}
for aid, a in _agent_nodes.items()]
edges = [{"from": a["upstream"], "to": aid}
for aid, a in _agent_nodes.items() if a.get("upstream")]
return {"nodes": nodes, "edges": edges, "servers": len(_mcp_servers),
"tools": len(_tool_registry), "active_sessions": sum(1 for s in _sessions.values() if s["status"] == "running")}
@router.get("/health")
async def mcp_health():
return {"status": "healthy", "servers": len(_mcp_servers), "agents": len(_agent_nodes),
"tools": len(_tool_registry), "sessions": len(_sessions), "open_network": _OPEN}

View File

@ -1,123 +0,0 @@
"""
CVE 패치 현황 API (모바일 기능 #82, #83).
GET /api/patches/cve CVE 목록 (severity 필터)
GET /api/patches/status 서버별 패치 적용률 (IP 노출 금지)
GET /api/patches/pending 미적용 패치 목록
GET /api/patches/pii-types PII 데이터 처리 유형 목록
POST /api/patches/{cve_id}/apply 패치 적용 SR 자동 생성
"""
from __future__ import annotations
import hashlib
from datetime import datetime, date
from typing import List, Optional
from fastapi import APIRouter, Depends
from pydantic import BaseModel, ConfigDict
from sqlalchemy import select, func
from sqlalchemy.ext.asyncio import AsyncSession
from core.auth import get_current_user
from database import get_db
from models import AuditLog, SRRequest, SRStatus, SRType, Priority, User
router = APIRouter(prefix="/api/patches", tags=["Patches"])
_MOCK_CVE = [
{"id": "CVE-2024-1001", "severity": "critical", "title": "OpenSSL 원격 코드 실행", "affected": "OpenSSL < 3.2.1", "cvss": 9.8, "patch_available": True, "patched_servers": 3, "total_servers": 10},
{"id": "CVE-2024-1002", "severity": "high", "title": "Apache httpd 디렉토리 탐색", "affected": "Apache < 2.4.59", "cvss": 7.5, "patch_available": True, "patched_servers": 8, "total_servers": 10},
{"id": "CVE-2024-1003", "severity": "high", "title": "Linux 커널 권한 상승", "affected": "kernel < 6.8.2", "cvss": 7.8, "patch_available": True, "patched_servers": 5, "total_servers": 10},
{"id": "CVE-2024-1004", "severity": "medium", "title": "SSH 취약 암호화 허용", "affected": "OpenSSH < 9.7", "cvss": 5.3, "patch_available": True, "patched_servers": 9, "total_servers": 10},
{"id": "CVE-2024-1005", "severity": "medium", "title": "Python urllib SSRF", "affected": "Python < 3.12.3", "cvss": 5.9, "patch_available": False, "patched_servers": 0, "total_servers": 10},
]
_MOCK_SERVERS = [
{"name": "WEB-01", "role": "웹서버", "patch_rate": 85, "pending": 2},
{"name": "WEB-02", "role": "웹서버", "patch_rate": 70, "pending": 4},
{"name": "APP-01", "role": "앱서버", "patch_rate": 95, "pending": 1},
{"name": "APP-02", "role": "앱서버", "patch_rate": 60, "pending": 5},
{"name": "DB-01", "role": "DB서버", "patch_rate": 100, "pending": 0},
]
_PII_TYPES = [
{"code": "PII_001", "name": "주민등록번호", "storage": "암호화 DB", "retention": "5년", "status": "compliant"},
{"code": "PII_002", "name": "연락처", "storage": "암호화 DB", "retention": "3년", "status": "compliant"},
{"code": "PII_003", "name": "이메일", "storage": "평문 로그", "retention": "미정", "status": "non_compliant"},
{"code": "PII_004", "name": "IP 주소", "storage": "감사 로그", "retention": "1년", "status": "compliant"},
]
class PatchApplyOut(BaseModel):
sr_id: int
message: str
@router.get("/cve")
async def list_cve(severity: Optional[str] = None):
data = _MOCK_CVE
if severity:
data = [c for c in data if c["severity"] == severity]
return {"total": len(data), "items": data}
@router.get("/status")
async def patch_status():
total_rate = round(sum(s["patch_rate"] for s in _MOCK_SERVERS) / len(_MOCK_SERVERS), 1)
return {"overall_patch_rate": total_rate, "servers": _MOCK_SERVERS}
@router.get("/pending")
async def pending_patches():
pending = [c for c in _MOCK_CVE if c["patched_servers"] < c["total_servers"]]
return {"total": len(pending), "items": pending}
@router.get("/pii-types")
async def pii_types():
return {"items": _PII_TYPES}
@router.post("/{cve_id}/apply", response_model=PatchApplyOut, status_code=201)
async def apply_patch(
cve_id: str,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""패치 적용 SR 자동 생성."""
cve = next((c for c in _MOCK_CVE if c["id"] == cve_id), None)
title = f"[패치] {cve['title'] if cve else cve_id} 적용"
sr = SRRequest(
sr_type=SRType.OTHER,
title=title,
description=f"CVE ID: {cve_id}\n적용 대상: {cve['affected'] if cve else '전체 서버'}",
status=SRStatus.RECEIVED,
priority=Priority.HIGH,
requested_by=current_user.username,
)
db.add(sr)
await db.flush()
prev = await db.execute(
select(AuditLog).order_by(AuditLog.id.desc()).limit(1)
)
prev_row = prev.scalar_one_or_none()
prev_hash = prev_row.log_hash if prev_row else "0" * 64
ts = datetime.now()
raw = f"{prev_hash}|{current_user.username}|PATCH_SR_CREATE|{title}|{ts.isoformat()}"
log_hash = hashlib.sha256(raw.encode()).hexdigest()
audit = AuditLog(
entity_type="sr_request",
entity_id=str(sr.sr_id),
actor=current_user.username,
action="PATCH_SR_CREATE",
detail=f"CVE {cve_id} 패치 적용 SR 생성",
log_hash=log_hash,
prev_hash=prev_hash,
created_at=ts,
)
db.add(audit)
await db.commit()
return PatchApplyOut(sr_id=sr.sr_id, message=f"SR #{sr.sr_id} 생성됨")

View File

@ -1,202 +0,0 @@
"""
GUARDiA 플랫폼 엔지니어링 (Platform Engineering) Gen6
IDP 고도화·골든 패스 템플릿·소프트웨어 카탈로그 v2·셀프서비스 인프라
"""
import uuid
from datetime import datetime
from typing import Any, Dict, List, Optional
from fastapi import APIRouter, HTTPException, Query
from pydantic import BaseModel
router = APIRouter(prefix="/api/platform", tags=["Platform Engineering"])
# ── 인메모리 스토어 ────────────────────────────────────────────────────────
_catalog: Dict[str, Dict] = {}
_templates: Dict[str, Dict] = {}
_environments: Dict[str, Dict] = {}
_service_levels: Dict[str, Dict] = {}
_requests: Dict[str, Dict] = {}
# ── 사전 로드 카탈로그 ────────────────────────────────────────────────────
def _init():
for svc in [
{"id": "svc-itsm", "name": "GUARDiA ITSM", "type": "backend", "language": "python", "version": "2.1.0", "owner": "platform-team", "status": "production"},
{"id": "svc-manager", "name": "GUARDiA Manager", "type": "frontend", "language": "typescript", "version": "1.5.0", "owner": "platform-team", "status": "production"},
{"id": "svc-messenger", "name": "GUARDiA Messenger", "type": "mobile", "language": "typescript", "version": "1.0.0", "owner": "mobile-team", "status": "production"},
{"id": "svc-web", "name": "zioinfo Homepage", "type": "fullstack", "language": "java+typescript", "version": "3.0.0", "owner": "web-team", "status": "production"},
]:
_catalog[svc["id"]] = svc
_init()
# ── 모델 ──────────────────────────────────────────────────────────────────
class ServiceCreate(BaseModel):
name: str; type: str; language: str; owner: str
description: str = ""; tags: List[str] = []
class TemplateCreate(BaseModel):
name: str; type: str # fastapi|react|react-native|springboot|ansible
description: str = ""; variables: Dict[str, Any] = {}
class EnvironmentCreate(BaseModel):
name: str; type: str = "dev" # dev|staging|prod|dr
services: List[str] = []; config: Dict[str, Any] = {}
class SelfServiceRequest(BaseModel):
service: str; action: str # create|scale|deploy|rollback|restart
params: Dict[str, Any] = {}; requested_by: str = "developer"
# ── 소프트웨어 카탈로그 ──────────────────────────────────────────────────
@router.get("/catalog")
async def list_catalog(type: Optional[str] = None, status: Optional[str] = None):
svcs = list(_catalog.values())
if type: svcs = [s for s in svcs if s.get("type") == type]
if status: svcs = [s for s in svcs if s.get("status") == status]
return {"services": svcs, "total": len(svcs)}
@router.post("/catalog")
async def add_service(svc: ServiceCreate):
sid = f"svc-{uuid.uuid4().hex[:8]}"
_catalog[sid] = {**svc.model_dump(), "id": sid, "status": "registered",
"version": "0.1.0", "created_at": datetime.utcnow().isoformat()}
return _catalog[sid]
@router.get("/catalog/{sid}")
async def get_service(sid: str):
s = _catalog.get(sid)
if not s: raise HTTPException(404)
return s
@router.get("/catalog/{sid}/dependencies")
async def service_dependencies(sid: str):
return {"service_id": sid, "depends_on": ["svc-itsm"], "depended_by": [],
"impact_level": "high" if sid == "svc-itsm" else "medium"}
@router.get("/catalog/{sid}/docs")
async def service_docs(sid: str):
s = _catalog.get(sid)
if not s: raise HTTPException(404)
return {"service_id": sid, "readme": f"# {s['name']}\n\n운영 문서",
"api_docs": f"/api/{sid}/docs", "runbook": f"/api/kb/runbook/{sid}"}
# ── 골든 패스 템플릿 ──────────────────────────────────────────────────────
@router.get("/templates")
async def list_templates():
BUILTIN = [
{"id": "tpl-fastapi", "name": "FastAPI 마이크로서비스", "type": "fastapi",
"features": ["JWT 인증", "SQLAlchemy", "Ollama AI", "CORS", "헬스체크"]},
{"id": "tpl-react-ts", "name": "React TypeScript SPA", "type": "react",
"features": ["Tailwind CSS", "React Query", "Zustand", "Vite", "다국어"]},
{"id": "tpl-rn-expo", "name": "React Native Expo", "type": "react-native",
"features": ["Expo SDK 51", "TypeScript", "Zustand", "WebSocket", "오프라인"]},
{"id": "tpl-springboot", "name": "Spring Boot API", "type": "springboot",
"features": ["JPA", "보안", "Swagger", "Actuator", "GraalVM"]},
{"id": "tpl-ansible", "name": "Ansible 플레이북", "type": "ansible",
"features": ["에이전트리스", "SSH", "멱등성", "태그", "롤백"]},
{"id": "tpl-k8s", "name": "K8s 배포 구성", "type": "kubernetes",
"features": ["Deployment", "Service", "HPA", "ConfigMap", "Secret"]},
]
custom = list(_templates.values())
return {"builtin": BUILTIN, "custom": custom, "total": len(BUILTIN) + len(custom)}
@router.post("/templates")
async def create_template(t: TemplateCreate):
tid = f"tpl-{uuid.uuid4().hex[:8]}"
_templates[tid] = {**t.model_dump(), "id": tid, "created_at": datetime.utcnow().isoformat()}
return _templates[tid]
@router.post("/templates/{tid}/scaffold")
async def scaffold_from_template(tid: str, project_name: str, variables: Dict[str, Any] = {}):
return {
"template_id": tid, "project_name": project_name, "variables": variables,
"scaffolded": True, "files_created": ["main.py", "models.py", "README.md", "Dockerfile", ".env.example"],
"next_steps": ["cd " + project_name, "pip install -r requirements.txt", "python main.py"],
"ts": datetime.utcnow().isoformat(),
}
# ── 환경 관리 ────────────────────────────────────────────────────────────
@router.get("/environments")
async def list_environments():
envs = list(_environments.values()) or [
{"id": "env-dev", "name": "개발", "type": "dev", "services": list(_catalog.keys())[:2], "health": "healthy"},
{"id": "env-prod", "name": "운영", "type": "prod", "services": list(_catalog.keys()), "health": "healthy"},
]
return {"environments": envs}
@router.post("/environments")
async def create_environment(env: EnvironmentCreate):
eid = f"env-{uuid.uuid4().hex[:8]}"
_environments[eid] = {**env.model_dump(), "id": eid, "health": "creating",
"created_at": datetime.utcnow().isoformat()}
return _environments[eid]
@router.get("/environments/{eid}/diff")
async def env_diff(eid: str, compare_with: str = "env-prod"):
return {"env1": eid, "env2": compare_with, "differences": [
{"type": "config", "key": "DB_URL", "env1": "localhost", "env2": "prod-db:5432"},
{"type": "version", "service": "guardia-itsm", "env1": "2.0.0", "env2": "2.1.0"},
]}
@router.post("/environments/{eid}/promote")
async def promote_environment(eid: str, target: str = Query(...)):
return {"from": eid, "to": target, "promoted": True, "ts": datetime.utcnow().isoformat()}
# ── 셀프서비스 인프라 ────────────────────────────────────────────────────
@router.post("/self-service")
async def self_service(req: SelfServiceRequest):
req_id = f"REQ-{uuid.uuid4().hex[:8].upper()}"
result = {
"request_id": req_id, "service": req.service, "action": req.action,
"params": req.params, "requested_by": req.requested_by,
"status": "approved", "auto_approved": True,
"ts": datetime.utcnow().isoformat(),
}
_requests[req_id] = result
return result
@router.get("/self-service")
async def list_requests(status: Optional[str] = None):
reqs = list(_requests.values())
if status: reqs = [r for r in reqs if r.get("status") == status]
return {"requests": reqs[-50:], "total": len(reqs)}
# ── 플랫폼 메트릭 ─────────────────────────────────────────────────────────
@router.get("/metrics")
async def platform_metrics():
return {
"services": {"total": len(_catalog), "healthy": len(_catalog), "degraded": 0},
"deployments_today": 8, "avg_deploy_time_min": 4.2,
"golden_path_adoption": 87.5, "self_service_requests_week": 34,
"developer_satisfaction": 4.7,
}
@router.get("/metrics/adoption")
async def golden_path_adoption():
return {
"fastapi_template": {"used": 12, "adoption_rate": 92.3},
"react_ts_template": {"used": 8, "adoption_rate": 88.1},
"ansible_template": {"used": 5, "adoption_rate": 76.4},
"overall": 87.5, "target": 95.0,
}
# ── 서비스 레벨 목표 ─────────────────────────────────────────────────────
@router.get("/slo")
async def list_slo():
return {"slos": [
{"service": "guardia-itsm", "availability_target": 99.9, "current": 99.95, "ok": True},
{"service": "guardia-manager", "availability_target": 99.5, "current": 99.8, "ok": True},
{"service": "guardia-messenger", "availability_target": 99.0, "current": 98.7, "ok": False},
]}
@router.post("/slo")
async def create_slo(service: str, availability_target: float, latency_p95_ms: int = 500):
slid = f"SLO-{uuid.uuid4().hex[:8].upper()}"
_service_levels[slid] = {"id": slid, "service": service,
"availability_target": availability_target,
"latency_p95_ms": latency_p95_ms,
"created_at": datetime.utcnow().isoformat()}
return _service_levels[slid]
@router.get("/health")
async def platform_health():
return {"status": "healthy", "services_monitored": len(_catalog),
"environments": len(_environments) or 2, "templates": 6}

View File

@ -1,202 +0,0 @@
"""
GUARDiA 공공기관 특화 v2 Gen6
K-CSAP v2·행정망 연동·나라장터 v2·행정전자서명·공공 클라우드·ISP 수립
"""
import uuid
from datetime import datetime, timedelta
from typing import Any, Dict, List, Optional
from fastapi import APIRouter, HTTPException, Query
from pydantic import BaseModel
router = APIRouter(prefix="/api/public2", tags=["Public Sector v2"])
_csap_checks: Dict[str, Dict] = {}
_procurement: Dict[str, Dict] = {}
_admin_net: Dict[str, Dict] = {}
_isp_plans: Dict[str, Dict] = {}
_signatures: Dict[str, Dict] = {}
class CSAPAuditCreate(BaseModel):
institution: str; audit_type: str = "quarterly" # quarterly|annual|special
scope: List[str] = ["all"]
class ProcurementCreate(BaseModel):
title: str; amount: float; category: str
contract_no: str = ""; start_date: str = ""; end_date: str = ""
class AdminNetRequest(BaseModel):
zone: str # admin|internet|dmz
service: str; protocol: str = "https"
approved_by: str
class ISPCreate(BaseModel):
institution: str; fiscal_year: int
total_budget: float; it_budget_ratio: float = 0.05
class ESignRequest(BaseModel):
document_id: str; signer: str; signature_type: str = "gpki" # gpki|accredited|rsa
# ── K-CSAP v2 ────────────────────────────────────────────────────────────
@router.post("/csap/audit")
async def create_csap_audit(audit: CSAPAuditCreate):
aid = f"CSAP-{uuid.uuid4().hex[:8].upper()}"
_csap_checks[aid] = {**audit.model_dump(), "id": aid, "status": "in_progress",
"compliance_rate": 0, "started_at": datetime.utcnow().isoformat()}
return _csap_checks[aid]
@router.get("/csap/audits")
async def list_csap_audits(): return {"audits": list(_csap_checks.values())}
@router.get("/csap/controls")
async def csap_controls():
"""CSAP 보안통제 항목 전체 목록."""
return {"categories": [
{"id": "M", "name": "관리적 보안", "items": 45, "passed": 42, "rate": 93.3},
{"id": "P", "name": "물리적 보안", "items": 20, "passed": 19, "rate": 95.0},
{"id": "T", "name": "기술적 보안", "items": 80, "passed": 73, "rate": 91.3},
], "total": 145, "passed": 134, "overall_rate": 92.4}
@router.get("/csap/report/{aid}")
async def csap_report(aid: str):
audit = _csap_checks.get(aid)
if not audit: raise HTTPException(404)
return {**audit, "compliance_rate": 92.4,
"findings": [{"control": "T-3.2", "status": "미흡", "recommendation": "패스워드 정책 강화"},
{"control": "M-1.5", "status": "보완", "recommendation": "보안 교육 주기 단축"}],
"next_audit": (datetime.utcnow() + timedelta(days=90)).isoformat()}
@router.get("/csap/gap-analysis")
async def csap_gap_analysis(institution: str = Query(...)):
return {"institution": institution, "gap_items": [
{"control": "T-5.1", "current_state": "미구현", "target": "구현", "priority": "high"},
{"control": "M-2.3", "current_state": "부분구현", "target": "완전구현", "priority": "medium"},
], "improvement_plan": "3개월 내 2개 항목 개선 계획"}
@router.post("/csap/self-check")
async def csap_self_check(institution: str, category: str = "all"):
return {"institution": institution, "category": category,
"checked_at": datetime.utcnow().isoformat(),
"score": 92.4, "grade": "우수",
"action_items": 3, "status": "completed"}
# ── 나라장터 v2 ────────────────────────────────────────────────────────────
@router.post("/g2b/procurement")
async def register_procurement(proc: ProcurementCreate):
pid = f"G2B-{uuid.uuid4().hex[:8].upper()}"
_procurement[pid] = {**proc.model_dump(), "id": pid, "status": "registered",
"registered_at": datetime.utcnow().isoformat()}
return _procurement[pid]
@router.get("/g2b/procurement")
async def list_procurement(status: Optional[str] = None):
procs = list(_procurement.values())
if status: procs = [p for p in procs if p.get("status") == status]
return {"procurements": procs, "total": len(procs)}
@router.get("/g2b/search")
async def search_g2b(keyword: str, category: str = "IT", page: int = 1):
return {"keyword": keyword, "category": category, "page": page,
"results": [
{"id": "G2B-001", "title": f"[{category}] {keyword} 시스템 구축", "amount": 150000000,
"deadline": "2026-07-15", "status": "공고중"},
{"id": "G2B-002", "title": f"{keyword} 유지보수 용역", "amount": 48000000,
"deadline": "2026-07-20", "status": "공고중"},
], "total": 2}
@router.get("/g2b/contract/{cid}")
async def get_contract(cid: str):
return {"contract_id": cid, "title": "GUARDiA ITSM 유지보수", "amount": 48000000,
"period": "2026-01-01 ~ 2026-12-31", "status": "계약중", "vendor": "지오정보기술"}
@router.post("/g2b/delivery-check")
async def delivery_check(contract_id: str, items: List[Dict[str, Any]]):
return {"contract_id": contract_id, "items_checked": len(items),
"status": "검수완료", "checked_at": datetime.utcnow().isoformat(),
"inspector": "담당자", "next_step": "세금계산서 발행"}
# ── 행정망 연동 관리 ─────────────────────────────────────────────────────
@router.post("/admin-net/request")
async def request_admin_net(req: AdminNetRequest):
rid = f"NET-{uuid.uuid4().hex[:8].upper()}"
_admin_net[rid] = {**req.model_dump(), "id": rid, "status": "pending",
"requested_at": datetime.utcnow().isoformat()}
return _admin_net[rid]
@router.get("/admin-net/topology")
async def admin_net_topology():
return {"zones": [
{"name": "행정망", "type": "admin", "services": ["ITSM", "CMDB"], "firewall_rules": 24},
{"name": "인터넷망", "type": "internet", "services": ["Homepage"], "firewall_rules": 12},
{"name": "DMZ", "type": "dmz", "services": ["Manager API"], "firewall_rules": 8},
], "connections": [
{"from": "admin", "to": "dmz", "protocol": "https", "status": "active"},
{"from": "internet", "to": "dmz", "protocol": "https", "status": "active"},
]}
@router.get("/admin-net/firewall-rules")
async def firewall_rules(zone: Optional[str] = None):
rules = [
{"id": "FW-001", "zone": "admin", "src": "10.0.0.0/8", "dst": "any", "port": 443, "action": "allow"},
{"id": "FW-002", "zone": "internet", "src": "any", "dst": "DMZ", "port": 443, "action": "allow"},
]
if zone: rules = [r for r in rules if r["zone"] == zone]
return {"rules": rules, "total": len(rules)}
# ── 행정전자서명 (GPKI) ────────────────────────────────────────────────────
@router.post("/esign/request")
async def esign_request(req: ESignRequest):
sid = f"SIG-{uuid.uuid4().hex[:8].upper()}"
_signatures[sid] = {**req.model_dump(), "id": sid, "status": "pending",
"requested_at": datetime.utcnow().isoformat()}
return _signatures[sid]
@router.post("/esign/verify")
async def esign_verify(signature_id: str):
sig = _signatures.get(signature_id)
if not sig: raise HTTPException(404)
return {"signature_id": signature_id, "valid": True, "signer": sig.get("signer"),
"signed_at": datetime.utcnow().isoformat(), "certificate": "행정기관인증서"}
# ── ISP 수립 지원 v2 ──────────────────────────────────────────────────────
@router.post("/isp")
async def create_isp(isp: ISPCreate):
iid = f"ISP-{uuid.uuid4().hex[:8].upper()}"
_isp_plans[iid] = {**isp.model_dump(), "id": iid, "status": "draft",
"it_budget": isp.total_budget * isp.it_budget_ratio,
"created_at": datetime.utcnow().isoformat()}
return _isp_plans[iid]
@router.get("/isp")
async def list_isp(): return {"plans": list(_isp_plans.values())}
@router.get("/isp/{iid}/roadmap")
async def isp_roadmap(iid: str):
isp = _isp_plans.get(iid)
if not isp: raise HTTPException(404)
return {"isp_id": iid, "roadmap": [
{"quarter": "Q1", "projects": ["ITSM 고도화"], "budget": 50000000},
{"quarter": "Q2", "projects": ["보안 강화"], "budget": 30000000},
{"quarter": "Q3", "projects": ["DR 구축"], "budget": 40000000},
{"quarter": "Q4", "projects": ["사용자 교육"], "budget": 10000000},
]}
# ── 공공 클라우드 (K-Cloud) ────────────────────────────────────────────────
@router.get("/kcloud/status")
async def kcloud_status():
return {"provider": "NCloud (공공)", "region": "kr-pub-1",
"services_deployed": 3, "cost_this_month": 1240000,
"compliance": "CSAP 인증 완료", "availability": "99.98%"}
@router.get("/kcloud/pricing")
async def kcloud_pricing(resource_type: str = "compute"):
pricing = {
"compute": [{"spec": "2vCPU/4GB", "price_hour": 85, "price_month": 61200}],
"storage": [{"spec": "100GB SSD", "price_month": 15000}],
"network": [{"spec": "공인IP", "price_month": 6600}],
}
return {"resource_type": resource_type, "pricing": pricing.get(resource_type, [])}
@router.get("/public2/health")
async def health():
return {"status": "healthy", "csap_audits": len(_csap_checks),
"procurement": len(_procurement), "signatures": len(_signatures)}

View File

@ -1,138 +0,0 @@
"""
통합 검색 API (모바일 기능 #50).
GET /api/search/?q={query}&types=sr,server,kb,institution
SR, 서버(CMDB), KB 문서, 기관을 동시에 검색하여 타입별 결과를 반환.
보안: 서버 결과는 ServerOut 안전 필드만 반환(ip_addr/ssh_user/os_pw_enc 제외).
CUSTOMER 역할은 자신의 기관 SR/서버만 조회.
"""
from __future__ import annotations
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import select, or_
from sqlalchemy.ext.asyncio import AsyncSession
from core.auth import get_current_user
from database import get_db
from models import (
Institution, KBDocument, Server, SRRequest, User, UserRole,
)
router = APIRouter(prefix="/api/search", tags=["Search"])
_PER_TYPE_LIMIT = 5
async def _customer_inst_id(user: User, db: AsyncSession) -> Optional[int]:
"""CUSTOMER 역할이면 소속 기관 id 반환, 아니면 None."""
if user.role == UserRole.CUSTOMER and user.inst_code:
inst = (await db.execute(
select(Institution).where(Institution.inst_code == user.inst_code)
)).scalars().first()
return inst.id if inst else -1
return None
@router.get("/")
async def global_search(
q: str = Query(..., min_length=1, description="검색어"),
types: str = Query("sr,server,kb,institution", description="콤마 구분 검색 대상"),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""SR + 서버 + KB + 기관 통합 검색."""
wanted = {t.strip() for t in types.split(",") if t.strip()}
if not wanted:
raise HTTPException(422, "types에 최소 하나의 검색 대상을 지정하세요.")
like = f"%{q}%"
results: dict = {}
cust_inst_id = await _customer_inst_id(current_user, db)
# ── SR 검색 ──────────────────────────────────────────────────────────
if "sr" in wanted:
sr_q = select(SRRequest).where(
or_(SRRequest.title.ilike(like),
SRRequest.description.ilike(like),
SRRequest.sr_id.ilike(like))
)
if cust_inst_id is not None:
sr_q = sr_q.where(SRRequest.inst_id == cust_inst_id)
sr_q = sr_q.order_by(SRRequest.created_at.desc()).limit(_PER_TYPE_LIMIT)
srs = (await db.execute(sr_q)).scalars().all()
results["sr"] = [
{
"sr_id": s.sr_id,
"title": s.title,
"status": s.status,
"priority": s.priority,
"sr_type": s.sr_type,
}
for s in srs
]
# ── 서버(CMDB) 검색 — 자격증명 필드 절대 제외 ──────────────────────────
if "server" in wanted:
srv_q = select(Server).where(
or_(Server.server_name.ilike(like),
Server.server_role.ilike(like),
Server.os_type.ilike(like))
)
if cust_inst_id is not None:
srv_q = srv_q.where(Server.inst_id == cust_inst_id)
srv_q = srv_q.limit(_PER_TYPE_LIMIT)
servers = (await db.execute(srv_q)).scalars().all()
results["server"] = [
{
"id": s.id,
"server_name": s.server_name,
"server_role": s.server_role,
"os_type": s.os_type,
"inst_id": s.inst_id,
# ip_addr / ssh_user / os_pw_enc 절대 미포함
}
for s in servers
]
# ── KB 검색 ──────────────────────────────────────────────────────────
if "kb" in wanted:
kb_q = select(KBDocument).where(
or_(KBDocument.title.ilike(like),
KBDocument.symptoms.ilike(like),
KBDocument.tags.ilike(like))
).limit(_PER_TYPE_LIMIT)
kbs = (await db.execute(kb_q)).scalars().all()
results["kb"] = [
{
"doc_id": k.doc_id,
"title": k.title,
"category": k.category,
"tags": k.tags,
}
for k in kbs
]
# ── 기관 검색 ────────────────────────────────────────────────────────
if "institution" in wanted:
inst_q = select(Institution).where(
or_(Institution.inst_name.ilike(like),
Institution.inst_code.ilike(like))
)
if cust_inst_id is not None and cust_inst_id != -1:
inst_q = inst_q.where(Institution.id == cust_inst_id)
inst_q = inst_q.limit(_PER_TYPE_LIMIT)
insts = (await db.execute(inst_q)).scalars().all()
results["institution"] = [
{
"id": i.id,
"inst_code": i.inst_code,
"inst_name": i.inst_name,
}
for i in insts
]
total = sum(len(v) for v in results.values())
return {"query": q, "total": total, "results": results}

View File

@ -1,280 +0,0 @@
"""
SR 채팅방 API + WebSocket (모바일 기능 #98).
WS /ws/sr-chat/{sr_id}?token={jwt} SR별 실시간 채팅
POST /api/sr-chat/{sr_id}/messages 메시지 전송 (REST)
GET /api/sr-chat/{sr_id}/messages 메시지 이력
POST /api/sr-chat/{sr_id}/read 읽음 처리
메시지 타입: text | image | sr_update
"""
from __future__ import annotations
import json
import logging
from datetime import datetime
from typing import Dict, List, Optional, Set
from fastapi import (
APIRouter, Depends, HTTPException, Query, WebSocket, WebSocketDisconnect,
)
from pydantic import BaseModel, ConfigDict
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from core.auth import get_current_user
from database import get_db, SessionLocal
from models import SRChatMessage, SRRequest, User
logger = logging.getLogger(__name__)
router = APIRouter(tags=["SR Chat"])
_VALID_MSG_TYPE = {"text", "image", "sr_update"}
# ── WebSocket 연결 관리 (SR방별 그룹) ─────────────────────────────────────────
class _ChatRooms:
def __init__(self) -> None:
# { sr_id: set(WebSocket) }
self._rooms: Dict[str, Set[WebSocket]] = {}
def join(self, sr_id: str, ws: WebSocket) -> None:
self._rooms.setdefault(sr_id, set()).add(ws)
def leave(self, sr_id: str, ws: WebSocket) -> None:
room = self._rooms.get(sr_id)
if room:
room.discard(ws)
if not room:
self._rooms.pop(sr_id, None)
async def broadcast(self, sr_id: str, payload: dict) -> None:
room = self._rooms.get(sr_id)
if not room:
return
msg = json.dumps(payload, ensure_ascii=False)
dead = []
for ws in list(room):
try:
await ws.send_text(msg)
except Exception:
dead.append(ws)
for ws in dead:
room.discard(ws)
rooms = _ChatRooms()
# ── 스키마 ────────────────────────────────────────────────────────────────────
class ChatMessageCreate(BaseModel):
content: str
msg_type: str = "text"
class ChatMessageOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
task_id: str
sender_id: str
content: str
msg_type: str
created_at: Optional[datetime]
# ── 헬퍼 ──────────────────────────────────────────────────────────────────────
async def _ensure_sr(sr_id: str, db: AsyncSession) -> SRRequest:
sr = (await db.execute(
select(SRRequest).where(SRRequest.sr_id == sr_id)
)).scalars().first()
if not sr:
raise HTTPException(404, "SR을 찾을 수 없습니다.")
return sr
async def _save_message(db: AsyncSession, sr_id: str, sender: str,
content: str, msg_type: str) -> SRChatMessage:
if msg_type not in _VALID_MSG_TYPE:
raise HTTPException(422, f"msg_type은 {_VALID_MSG_TYPE} 중 하나여야 합니다.")
if not content or not content.strip():
raise HTTPException(422, "메시지 내용이 비어 있습니다.")
m = SRChatMessage(
task_id=sr_id,
sender_id=sender,
content=content,
msg_type=msg_type,
read_by=json.dumps([sender], ensure_ascii=False),
)
db.add(m)
await db.commit()
await db.refresh(m)
return m
# ── REST: 메시지 전송 ─────────────────────────────────────────────────────────
@router.post("/api/sr-chat/{sr_id}/messages", response_model=ChatMessageOut, status_code=201)
async def send_message(
sr_id: str,
payload: ChatMessageCreate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""SR 채팅 메시지 전송 (REST). 연결된 WebSocket 구독자에게도 브로드캐스트."""
await _ensure_sr(sr_id, db)
m = await _save_message(db, sr_id, current_user.username,
payload.content, payload.msg_type)
await rooms.broadcast(sr_id, {
"type": "message",
"id": m.id,
"task_id": sr_id,
"sender_id": m.sender_id,
"content": m.content,
"msg_type": m.msg_type,
"created_at": m.created_at.isoformat() if m.created_at else None,
})
return m
# ── REST: 메시지 이력 ─────────────────────────────────────────────────────────
@router.get("/api/sr-chat/{sr_id}/messages", response_model=List[ChatMessageOut])
async def list_messages(
sr_id: str,
skip: int = 0,
limit: int = 100,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""SR 채팅 메시지 이력 (오래된 순)."""
await _ensure_sr(sr_id, db)
rows = (await db.execute(
select(SRChatMessage)
.where(SRChatMessage.task_id == sr_id)
.order_by(SRChatMessage.created_at.asc())
.offset(skip).limit(min(limit, 500))
)).scalars().all()
return rows
# ── REST: 읽음 처리 ───────────────────────────────────────────────────────────
@router.post("/api/sr-chat/{sr_id}/read")
async def mark_read(
sr_id: str,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""현재 사용자가 SR 채팅의 모든 메시지를 읽음 처리."""
await _ensure_sr(sr_id, db)
rows = (await db.execute(
select(SRChatMessage).where(SRChatMessage.task_id == sr_id)
)).scalars().all()
updated = 0
for m in rows:
try:
readers = json.loads(m.read_by) if m.read_by else []
except Exception:
readers = []
if current_user.username not in readers:
readers.append(current_user.username)
m.read_by = json.dumps(readers, ensure_ascii=False)
updated += 1
await db.commit()
return {"sr_id": sr_id, "marked_read": updated, "reader": current_user.username}
# ── WebSocket: 실시간 채팅 ────────────────────────────────────────────────────
async def _authenticate_ws(token: str, db: AsyncSession) -> Optional[User]:
if not token:
return None
try:
from core.auth import SECRET_KEY, ALGORITHM
from jose import jwt
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
if payload.get("mfa_pending"):
return None
username = payload.get("sub")
if not username:
return None
user = (await db.execute(
select(User).where(User.username == username)
)).scalars().first()
return user if (user and user.is_active) else None
except Exception:
return None
@router.websocket("/ws/sr-chat/{sr_id}")
async def sr_chat_ws(
websocket: WebSocket,
sr_id: str,
token: str = Query(..., description="JWT access_token"),
db: AsyncSession = Depends(get_db),
):
"""SR별 실시간 채팅 WebSocket."""
user = await _authenticate_ws(token, db)
if not user:
await websocket.close(code=4001, reason="인증 실패: 유효한 토큰이 필요합니다.")
return
# SR 존재 확인
sr = (await db.execute(
select(SRRequest).where(SRRequest.sr_id == sr_id)
)).scalars().first()
if not sr:
await websocket.close(code=4004, reason="SR을 찾을 수 없습니다.")
return
await websocket.accept()
rooms.join(sr_id, websocket)
await websocket.send_text(json.dumps({
"type": "connected",
"sr_id": sr_id,
"username": user.username,
"server_time": datetime.now().isoformat(),
}, ensure_ascii=False))
try:
while True:
raw = await websocket.receive_text()
try:
data = json.loads(raw)
except Exception:
await websocket.send_text(json.dumps(
{"type": "error", "message": "JSON 형식이 아닙니다."},
ensure_ascii=False))
continue
if data.get("type") == "ping":
await websocket.send_text(json.dumps(
{"type": "pong", "server_time": datetime.now().isoformat()},
ensure_ascii=False))
continue
content = (data.get("content") or "").strip()
msg_type = data.get("msg_type", "text")
if not content or msg_type not in _VALID_MSG_TYPE:
await websocket.send_text(json.dumps(
{"type": "error", "message": "content 또는 msg_type이 올바르지 않습니다."},
ensure_ascii=False))
continue
# DB 저장 (독립 세션) + 구독자에게 브로드캐스트
async with SessionLocal() as _db:
m = await _save_message(_db, sr_id, user.username, content, msg_type)
payload = {
"type": "message",
"id": m.id,
"task_id": sr_id,
"sender_id": m.sender_id,
"content": m.content,
"msg_type": m.msg_type,
"created_at": m.created_at.isoformat() if m.created_at else None,
}
await rooms.broadcast(sr_id, payload)
except WebSocketDisconnect:
pass
except Exception as exc:
logger.debug("SR 채팅 WS 오류: sr=%s err=%s", sr_id, exc)
finally:
rooms.leave(sr_id, websocket)

View File

@ -1,168 +0,0 @@
"""
통계·보고 API (모바일 기능 #93~#97).
GET /api/stats/my 나의 SR 처리 통계
GET /api/stats/institutions 기관별 SR 현황 비교
GET /api/stats/deploy-history 배포 이력 타임라인 (VibeSession)
GET /api/stats/kpi KPI 대시보드
GET /api/stats/export-pdf 리포트 JSON (앱에서 PDF 변환)
"""
from __future__ import annotations
from datetime import datetime, timedelta
from typing import Optional
from fastapi import APIRouter, Depends
from sqlalchemy import select, func, case
from sqlalchemy.ext.asyncio import AsyncSession
from core.auth import get_current_user
from database import get_db
from models import (
SRRequest, SRStatus, Institution, User, UserRole, VibeSession,
)
router = APIRouter(prefix="/api/stats", tags=["Statistics"])
def _this_month():
now = datetime.now()
return datetime(now.year, now.month, 1)
async def _inst_ids_for(user: User, db: AsyncSession):
if user.role != UserRole.CUSTOMER:
return None
rows = (await db.execute(
select(Institution.inst_id).where(Institution.inst_code == user.inst_code)
)).scalars().all()
return rows or [-1]
@router.get("/my")
async def my_stats(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
now = datetime.now()
this_m = datetime(now.year, now.month, 1)
last_m = datetime(now.year, now.month - 1, 1) if now.month > 1 else datetime(now.year - 1, 12, 1)
base = select(SRRequest).where(SRRequest.requested_by == current_user.username)
async def _count(q):
return (await db.execute(select(func.count()).select_from(q.subquery()))).scalar_one()
total = await _count(base)
this_done = await _count(base.where(SRRequest.status == SRStatus.COMPLETED, SRRequest.created_at >= this_m))
last_done = await _count(base.where(SRRequest.status == SRStatus.COMPLETED, SRRequest.created_at >= last_m, SRRequest.created_at < this_m))
this_all = await _count(base.where(SRRequest.created_at >= this_m))
last_all = await _count(base.where(SRRequest.created_at >= last_m, SRRequest.created_at < this_m))
return {
"total": total,
"this_month": {"created": this_all, "completed": this_done, "rate": round(this_done / this_all * 100, 1) if this_all else 0},
"last_month": {"created": last_all, "completed": last_done, "rate": round(last_done / last_all * 100, 1) if last_all else 0},
}
@router.get("/institutions")
async def institution_stats(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
q = (
select(
Institution.inst_id,
Institution.inst_name,
func.count(SRRequest.sr_id).label("total"),
func.sum(case((SRRequest.status == SRStatus.COMPLETED, 1), else_=0)).label("done"),
)
.outerjoin(SRRequest, SRRequest.inst_id == Institution.inst_id)
.group_by(Institution.inst_id, Institution.inst_name)
.order_by(func.count(SRRequest.sr_id).desc())
)
rows = (await db.execute(q)).all()
return {
"items": [
{
"inst_id": r.inst_id,
"inst_name": r.inst_name,
"total": r.total or 0,
"completed": r.done or 0,
"rate": round((r.done or 0) / r.total * 100, 1) if r.total else 0,
}
for r in rows
]
}
@router.get("/deploy-history")
async def deploy_history(
limit: int = 30,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
q = (
select(VibeSession)
.order_by(VibeSession.started_at.desc())
.limit(limit)
)
rows = (await db.execute(q)).scalars().all()
return {
"items": [
{
"id": r.id,
"project": r.project_name if hasattr(r, "project_name") else "N/A",
"status": r.status,
"started_at": r.started_at.isoformat() if r.started_at else None,
"deployed_at": r.deployed_at.isoformat() if r.deployed_at else None,
"duration_sec": int((r.deployed_at - r.started_at).total_seconds()) if r.deployed_at and r.started_at else None,
"deployed_by": r.requested_by if hasattr(r, "requested_by") else None,
}
for r in rows
]
}
@router.get("/kpi")
async def kpi_dashboard(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
now = datetime.now()
month_start = datetime(now.year, now.month, 1)
total_sr = (await db.execute(select(func.count(SRRequest.sr_id)).where(SRRequest.created_at >= month_start))).scalar_one()
done_sr = (await db.execute(select(func.count(SRRequest.sr_id)).where(SRRequest.created_at >= month_start, SRRequest.status == SRStatus.COMPLETED))).scalar_one()
breach = (await db.execute(select(func.count(SRRequest.sr_id)).where(SRRequest.created_at >= month_start, SRRequest.sla_breached == True))).scalar_one()
return {
"period": month_start.strftime("%Y-%m"),
"sr_completion_rate": round(done_sr / total_sr * 100, 1) if total_sr else 0,
"sla_compliance_rate": round((total_sr - breach) / total_sr * 100, 1) if total_sr else 100,
"total_sr": total_sr,
"completed_sr": done_sr,
"sla_breach": breach,
"csap_score": 82.5,
"targets": {
"sr_completion_rate": 90,
"sla_compliance_rate": 95,
"csap_score": 85,
},
}
@router.get("/export-pdf")
async def export_pdf_data(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
kpi = await kpi_dashboard(db=db, current_user=current_user)
my = await my_stats(db=db, current_user=current_user)
return {
"generated_at": datetime.now().isoformat(),
"generated_by": current_user.username,
"kpi": kpi,
"my_stats": my,
}

View File

@ -1,92 +0,0 @@
"""
시스템 정보 API (모바일 기능 #77).
GET /api/system/release-notes 버전별 릴리즈 노트 목록
GET /api/system/version 현재 버전 정보
릴리즈 노트는 정적 정의(코드 내장) 제공한다.
"""
from __future__ import annotations
from typing import List, Optional
from fastapi import APIRouter, Depends
from pydantic import BaseModel
from core.auth import get_current_user
from models import User
router = APIRouter(prefix="/api/system", tags=["System"])
CURRENT_VERSION = "2.0.0"
# 최신 → 과거 순서
_RELEASE_NOTES: List[dict] = [
{
"version": "2.0.0",
"date": "2026-06-06",
"changes": [
"모바일 100기능 백엔드 API 추가 (알림규칙·통합검색·SR채팅·부품재고)",
"SR 에스컬레이션/구독/만족도/현장서명/체크인 지원",
"보안 이벤트 로그 및 디바이스 관리 추가",
"다단계 승인 현황 및 변경 달력 API 추가",
],
"breaking_changes": [],
},
{
"version": "1.5.0",
"date": "2026-06-01",
"changes": [
"CI/CD 배포 트리거 연동",
"tmux 세션 관리·하네스 빌더 추가",
"AI-SOC·데이터 거버넌스 영역 확장",
],
"breaking_changes": [],
},
{
"version": "1.0.0",
"date": "2026-05-20",
"changes": [
"GUARDiA ITSM 정식 출시",
"SR 라이프사이클·CMDB·KB·SLA·승인 워크플로우",
],
"breaking_changes": [],
},
]
class ReleaseNote(BaseModel):
version: str
date: str
changes: List[str]
breaking_changes: List[str] = []
@router.get("/release-notes", response_model=List[ReleaseNote])
async def list_release_notes(
since: Optional[str] = None,
_u: User = Depends(get_current_user),
):
"""버전별 릴리즈 노트 목록 (최신순). since 지정 시 해당 버전 이후만."""
notes = _RELEASE_NOTES
if since:
# since 버전 이후(미포함)만 반환
filtered = []
for n in notes:
if n["version"] == since:
break
filtered.append(n)
notes = filtered
return notes
@router.get("/version")
async def get_version(_u: User = Depends(get_current_user)):
"""현재 버전 정보."""
latest = _RELEASE_NOTES[0] if _RELEASE_NOTES else None
return {
"name": "GUARDiA ITSM",
"version": CURRENT_VERSION,
"latest_release_date": latest["date"] if latest else None,
"release_count": len(_RELEASE_NOTES),
}

View File

@ -13,8 +13,7 @@ from core.events import broadcast
from database import get_db
from models import (
AuditLog, Institution, SRCreate, SROut, SRRequest, SRStatus,
SRStatusUpdate, SRType, User, compute_log_hash,
SRSubscription, SRRating, SRSignature, SRCheckin,
SRStatusUpdate, SRType, User, compute_log_hash
)
router = APIRouter(prefix="/api/tasks", tags=["tasks"])
@ -508,301 +507,3 @@ async def bulk_sr_action(
"results": results,
}
# ════════════════════════════════════════════════════════════════════════════════
# ── 모바일 100기능: SR 액션 + 통계 ──────────────────────────────────────────────
# ════════════════════════════════════════════════════════════════════════════════
class EscalateRequest(BaseModel):
reason: Optional[str] = None
escalate_to: Optional[str] = None # 미지정 시 온콜 에스컬레이션 체인 사용
class RatingRequest(BaseModel):
score: int
comment: Optional[str] = None
class SignatureRequest(BaseModel):
signature_base64: str
class SlaExceptionRequest(BaseModel):
reason: str
new_deadline: str # ISO datetime/date
class CheckinRequest(BaseModel):
lat: float
lng: float
async def _get_sr(sr_id: str, db: AsyncSession) -> SRRequest:
sr = (await db.execute(
select(SRRequest).where(SRRequest.sr_id == sr_id)
)).scalars().first()
if not sr:
raise HTTPException(404, detail="SR을 찾을 수 없습니다.")
return sr
@router.post("/{sr_id}/escalate")
async def escalate_task(
sr_id: str,
payload: EscalateRequest,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""SR 에스컬레이션 — 상위 담당자로 재배정 + 알림 (#8)."""
from models import UserRole
if current_user.role == UserRole.CUSTOMER:
raise HTTPException(403, "에스컬레이션 권한이 없습니다.")
sr = await _get_sr(sr_id, db)
target = payload.escalate_to
if not target:
# 온콜 에스컬레이션 체인에서 대상 도출 시도
try:
from core.oncall_rotate import get_current_oncall
sched = await get_current_oncall(db)
if sched:
target = sched.escalation_to or sched.backup_engineer or sched.engineer
except Exception:
target = None
sr.escalated_at = datetime.now()
sr.escalated_to = target
if target:
sr.assigned_to = target
sr.updated_at = datetime.now()
await _write_audit(db, sr_id, current_user.username, "SR_ESCALATED",
f"에스컬레이션 → {target or '미정'} | 사유: {payload.reason or ''}")
await db.commit()
await broadcast("sla_escalated", {
"sr_id": sr_id, "escalated_to": target, "by": current_user.username,
})
return {
"sr_id": sr_id,
"escalated_to": target,
"escalated_at": sr.escalated_at.isoformat(),
"message": f"'{target or '대상 미정'}'(으)로 에스컬레이션되었습니다.",
}
@router.post("/{sr_id}/subscribe")
async def toggle_subscribe(
sr_id: str,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""SR 구독/팔로우 토글 (#14)."""
await _get_sr(sr_id, db)
existing = (await db.execute(
select(SRSubscription).where(
SRSubscription.task_id == sr_id,
SRSubscription.username == current_user.username,
)
)).scalars().first()
if existing:
await db.delete(existing)
await db.commit()
return {"sr_id": sr_id, "subscribed": False, "message": "구독을 해제했습니다."}
sub = SRSubscription(task_id=sr_id, username=current_user.username)
db.add(sub)
await db.commit()
return {"sr_id": sr_id, "subscribed": True, "message": "구독했습니다."}
@router.post("/{sr_id}/rating", status_code=201)
async def rate_task(
sr_id: str,
payload: RatingRequest,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""완료 SR 만족도 평가 (#53)."""
if not (1 <= payload.score <= 5):
raise HTTPException(422, "score는 1~5 범위여야 합니다.")
sr = await _get_sr(sr_id, db)
if sr.status != SRStatus.COMPLETED:
raise HTTPException(400, "완료된 SR만 평가할 수 있습니다.")
rating = SRRating(
task_id=sr_id, rater=current_user.username,
score=payload.score, comment=payload.comment,
)
db.add(rating)
await _write_audit(db, sr_id, current_user.username, "SR_RATED",
f"만족도 {payload.score}")
await db.commit()
await db.refresh(rating)
return {"sr_id": sr_id, "score": payload.score, "rating_id": rating.id}
@router.post("/{sr_id}/signature", status_code=201)
async def save_signature(
sr_id: str,
payload: SignatureRequest,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""현장 전자서명 저장 (#70)."""
if not payload.signature_base64 or len(payload.signature_base64) < 10:
raise HTTPException(422, "signature_base64 데이터가 올바르지 않습니다.")
await _get_sr(sr_id, db)
sig = SRSignature(
task_id=sr_id, signed_by=current_user.username,
signature_b64=payload.signature_base64,
)
db.add(sig)
await _write_audit(db, sr_id, current_user.username, "SR_SIGNED", "현장 서명 등록")
await db.commit()
await db.refresh(sig)
return {"sr_id": sr_id, "signature_id": sig.id, "signed_by": current_user.username}
@router.post("/{sr_id}/sla-exception")
async def request_sla_exception(
sr_id: str,
payload: SlaExceptionRequest,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""SLA 예외 승인 요청 — SLA 마감 연장 요청 (#94)."""
sr = await _get_sr(sr_id, db)
try:
new_dl = datetime.fromisoformat(payload.new_deadline)
except ValueError:
raise HTTPException(422, "new_deadline은 ISO 날짜/시간 형식이어야 합니다.")
old_dl = sr.sla_deadline
sr.sla_deadline = new_dl
sr.sla_breached = False
sr.updated_at = datetime.now()
await _write_audit(db, sr_id, current_user.username, "SLA_EXCEPTION_REQUESTED",
f"SLA 마감 {old_dl}{new_dl} | 사유: {payload.reason}")
await db.commit()
return {
"sr_id": sr_id,
"old_deadline": old_dl.isoformat() if old_dl else None,
"new_deadline": new_dl.isoformat(),
"reason": payload.reason,
"message": "SLA 예외(마감 연장)가 적용되었습니다.",
}
@router.post("/{sr_id}/checkin", status_code=201)
async def checkin_task(
sr_id: str,
payload: CheckinRequest,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""현장 체크인 — GPS 좌표 기록 (#93)."""
if not (-90 <= payload.lat <= 90) or not (-180 <= payload.lng <= 180):
raise HTTPException(422, "좌표 값이 올바르지 않습니다.")
await _get_sr(sr_id, db)
chk = SRCheckin(
task_id=sr_id, username=current_user.username,
lat=payload.lat, lng=payload.lng,
)
db.add(chk)
await _write_audit(db, sr_id, current_user.username, "SR_CHECKIN",
f"현장 체크인 ({payload.lat}, {payload.lng})")
await db.commit()
await db.refresh(chk)
return {
"sr_id": sr_id,
"checkin_id": chk.id,
"lat": payload.lat,
"lng": payload.lng,
"checked_in_at": chk.created_at.isoformat() if chk.created_at else None,
}
@router.get("/stats/mine")
async def my_sr_stats(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""내 SR 처리 통계 — total / closed / avg_resolve_hours / sla_met_rate (#3)."""
rows = (await db.execute(
select(SRRequest).where(SRRequest.assigned_to == current_user.username)
)).scalars().all()
total = len(rows)
terminal = {SRStatus.COMPLETED, SRStatus.REJECTED, SRStatus.FAILED_ROLLBACK}
closed_rows = [r for r in rows if r.status in terminal]
closed = len(closed_rows)
# 평균 해결 시간 (완료 건의 created_at→updated_at)
completed = [r for r in rows if r.status == SRStatus.COMPLETED
and r.created_at and r.updated_at]
avg_hours = 0.0
if completed:
secs = sum((r.updated_at - r.created_at).total_seconds() for r in completed)
avg_hours = round(secs / len(completed) / 3600, 2)
# SLA 준수율 (마감 시각이 있는 건 중 미위반 비율)
sla_rows = [r for r in rows if r.sla_deadline is not None]
sla_met = len([r for r in sla_rows if not r.sla_breached])
sla_met_rate = round(sla_met / len(sla_rows) * 100, 1) if sla_rows else 100.0
return {
"username": current_user.username,
"total": total,
"closed": closed,
"open": total - closed,
"avg_resolve_hours": avg_hours,
"sla_met_rate": sla_met_rate,
}
@router.get("/stats/by-institution")
async def stats_by_institution(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""기관별 SR 현황 비교 (#4). CUSTOMER는 자기 기관만."""
from models import UserRole
q = select(SRRequest)
cust_inst_id = None
if current_user.role == UserRole.CUSTOMER and current_user.inst_code:
inst = (await db.execute(
select(Institution).where(Institution.inst_code == current_user.inst_code)
)).scalars().first()
cust_inst_id = inst.id if inst else -1
q = q.where(SRRequest.inst_id == cust_inst_id)
srs = (await db.execute(q)).scalars().all()
# 기관 이름 매핑
insts = (await db.execute(select(Institution))).scalars().all()
name_map = {i.id: i.inst_name for i in insts}
terminal = {SRStatus.COMPLETED, SRStatus.REJECTED, SRStatus.FAILED_ROLLBACK}
agg: dict = {}
for s in srs:
key = s.inst_id
bucket = agg.setdefault(key, {
"inst_id": key,
"inst_name": name_map.get(key, "미지정"),
"total": 0, "closed": 0, "open": 0, "sla_breached": 0,
})
bucket["total"] += 1
if s.status in terminal:
bucket["closed"] += 1
else:
bucket["open"] += 1
if s.sla_breached:
bucket["sla_breached"] += 1
result = sorted(agg.values(), key=lambda x: x["total"], reverse=True)
return {"institutions": result, "count": len(result)}