From 5407bf4780be97f463692f0942a6fed31a0a50c1 Mon Sep 17 00:00:00 2001 From: "DESKTOP-TKLFCPR\\ython" Date: Sun, 7 Jun 2026 11:13:54 +0900 Subject: [PATCH] manual-deploy 2026-06-07 11:13 --- .env.open | 31 +++++ guardia_itsm.db | Bin 0 -> 954368 bytes routers/approvals.py | 156 ++++++++++++++++++++++ routers/auth.py | 154 +++++++++++++++++++++- routers/tasks.py | 301 ++++++++++++++++++++++++++++++++++++++++++- 5 files changed, 640 insertions(+), 2 deletions(-) create mode 100644 .env.open create mode 100644 guardia_itsm.db diff --git a/.env.open b/.env.open new file mode 100644 index 0000000..f778f23 --- /dev/null +++ b/.env.open @@ -0,0 +1,31 @@ +# GUARDiA ITSM — 개방망(Open Network) 운영 환경 설정 +# 사용법: cp .env.open .env 후 systemctl restart guardia + +# ── 네트워크 모드 ───────────────────────────────────────────────────────────── +GUARDIA_NETWORK_MODE=open + +# 허용 외부 출처 (쉼표 구분, HTTPS 도메인 또는 IP) +# 예) https://itsm.zioinfo.co.kr,https://portal.myorg.go.kr +GUARDIA_ALLOWED_ORIGINS=http://zioinfo.co.kr,https://zioinfo.co.kr + +# ── 웹훅 보안 시크릿 ───────────────────────────────────────────────────────── +# 외부 메신저 웹훅 HMAC 서명 검증용 — 반드시 변경하세요 +GUARDIA_WEBHOOK_SECRET=guardia-webhook-secret-change-me-2026 + +# ── AI 엔진 (온프레미스 전용 — 절대 외부 API 사용 금지) ────────────────────── +OLLAMA_BASE_URL=http://localhost:11434 +LLM_MODEL=llama3:8b + +# ── 데이터베이스 ────────────────────────────────────────────────────────────── +DATABASE_URL=postgresql+asyncpg://guardia:G@urd1a_2026!@localhost:5432/guardia_db + +# ── JWT 인증 ────────────────────────────────────────────────────────────────── +GUARDIA_JWT_SECRET=guardia-jwt-production-secret-2026-please-change + +# ── Rate Limiting (개방망 강화) ─────────────────────────────────────────────── +RATE_LIMIT_PER_MINUTE=60 +RATE_LIMIT_BURST=10 + +# ── 로그 ───────────────────────────────────────────────────────────────────── +LOG_LEVEL=INFO +LOG_FILE=/opt/guardia/logs/guardia.log diff --git a/guardia_itsm.db b/guardia_itsm.db new file mode 100644 index 0000000000000000000000000000000000000000..51a7503ad3b03e9495b9355324f0f85f7c55ace2 GIT binary patch literal 954368 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 start_dt, + SRRequest.created_at < end_dt, + ) + # CUSTOMER 기관 필터 + from models import UserRole + if current_user.role == UserRole.CUSTOMER and current_user.inst_code: + from models import Institution as _Inst + inst = (await db.execute( + select(_Inst).where(_Inst.inst_code == current_user.inst_code) + )).scalars().first() + q = q.where(SRRequest.inst_id == (inst.id if inst else -1)) + + srs = (await db.execute(q.order_by(SRRequest.created_at))).scalars().all() + + by_date: dict = {} + for s in srs: + if not s.created_at: + continue + key = s.created_at.date().isoformat() + by_date.setdefault(key, []).append({ + "sr_id": s.sr_id, + "title": s.title, + "sr_type": s.sr_type, + "status": s.status, + "priority": s.priority, + }) + + return {"month": month, "calendar": by_date, "total": len(srs)} diff --git a/routers/auth.py b/routers/auth.py index 5ee96c8..ba79216 100644 --- a/routers/auth.py +++ b/routers/auth.py @@ -10,7 +10,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from core.auth import (create_access_token, get_current_user, hash_password, verify_password, MAX_FAILED_ATTEMPTS, LOCKOUT_MINUTES) from database import get_db -from models import User +from models import User, UserDevice, LoginEvent, Institution router = APIRouter(prefix="/api/auth", tags=["auth"]) @@ -453,6 +453,158 @@ async def admin_user_lock_status( } +# ════════════════════════════════════════════════════════════════════════════════ +# ── 모바일 100기능: 디바이스 / 보안 이벤트 / 네트워크 상태 / 기관 전환 ─────────── +# ════════════════════════════════════════════════════════════════════════════════ + +class DeviceRegister(BaseModel): + device_name: Optional[str] = None + device_type: Optional[str] = None # android | ios | web + push_token: Optional[str] = None + + +class SwitchTenantRequest(BaseModel): + tenant_id: str # 전환 대상 inst_code + + +@router.get("/devices") +async def list_devices( + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """등록 디바이스 목록 (#33).""" + rows = (await db.execute( + select(UserDevice).where(UserDevice.username == current_user.username) + .order_by(UserDevice.last_seen_at.desc()) + )).scalars().all() + return [ + { + "id": d.id, + "device_name": d.device_name, + "device_type": d.device_type, + "last_seen_at": d.last_seen_at.isoformat() if d.last_seen_at else None, + "created_at": d.created_at.isoformat() if d.created_at else None, + } + for d in rows + ] + + +@router.post("/devices", status_code=201) +async def register_device( + body: DeviceRegister, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """디바이스 등록/갱신 (#33).""" + dev = UserDevice( + username=current_user.username, + device_name=body.device_name, + device_type=body.device_type, + push_token=body.push_token, + last_seen_at=datetime.now(), + ) + db.add(dev) + db.add(LoginEvent(username=current_user.username, event_type="DEVICE_ADDED", + detail=f"{body.device_type or '?'} / {body.device_name or '?'}")) + await db.commit() + await db.refresh(dev) + return {"id": dev.id, "message": "디바이스가 등록되었습니다."} + + +@router.delete("/devices/{device_id}", status_code=204) +async def unregister_device( + device_id: int, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """디바이스 등록 해제 (#33).""" + dev = await db.get(UserDevice, device_id) + if not dev or dev.username != current_user.username: + raise HTTPException(404, "디바이스를 찾을 수 없습니다.") + await db.delete(dev) + db.add(LoginEvent(username=current_user.username, event_type="DEVICE_REMOVED", + detail=f"device_id={device_id}")) + await db.commit() + + +@router.get("/events") +async def list_security_events( + limit: int = 50, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """보안 이벤트 로그 — 로그인 이력/실패/디바이스 변경 등 (#34).""" + rows = (await db.execute( + select(LoginEvent).where(LoginEvent.username == current_user.username) + .order_by(LoginEvent.created_at.desc()) + .limit(min(limit, 200)) + )).scalars().all() + return [ + { + "id": e.id, + "event_type": e.event_type, + "detail": e.detail, + "created_at": e.created_at.isoformat() if e.created_at else None, + } + for e in rows + ] + + +@router.get("/network-status") +async def network_status( + current_user: User = Depends(get_current_user), +): + """접속 경로 상태 — VPN / 개방망 / 내부망 (#37).""" + import os as _os + mode = _os.environ.get("GUARDIA_NETWORK_MODE", "internal") + if mode == "open": + via, level = "opennet", 2 + elif mode == "vpn": + via, level = "vpn", 2 + else: + via, level = "internal", 3 # 내부망이 가장 신뢰 수준 높음 + return { + "via": via, + "level": level, + "username": current_user.username, + "checked_at": datetime.now().isoformat(), + } + + +@router.post("/switch-tenant") +async def switch_tenant( + body: SwitchTenantRequest, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """기관 전환 — 새 inst_code로 전환 후 새 JWT 발급 (#33/멀티기관).""" + inst = (await db.execute( + select(Institution).where(Institution.inst_code == body.tenant_id) + )).scalars().first() + if not inst: + raise HTTPException(404, "전환 대상 기관을 찾을 수 없습니다.") + + # CUSTOMER는 자기 기관으로만 제한, ADMIN/PM/ENGINEER는 자유 전환 + from models import UserRole + if current_user.role == UserRole.CUSTOMER and current_user.inst_code != body.tenant_id: + raise HTTPException(403, "해당 기관으로 전환할 권한이 없습니다.") + + token = create_access_token({ + "sub": current_user.username, + "role": current_user.role, + "tenant": body.tenant_id, + }) + db.add(LoginEvent(username=current_user.username, event_type="TENANT_SWITCH", + detail=f"→ {body.tenant_id}")) + await db.commit() + return { + "access_token": token, + "token_type": "bearer", + "tenant_id": body.tenant_id, + "inst_name": inst.inst_name, + } + + # ── OAuth2 소셜 로그인 ──────────────────────────────────────────────────────── @router.get("/oauth/providers") diff --git a/routers/tasks.py b/routers/tasks.py index 0e7d8cf..dc82067 100644 --- a/routers/tasks.py +++ b/routers/tasks.py @@ -13,7 +13,8 @@ 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 + SRStatusUpdate, SRType, User, compute_log_hash, + SRSubscription, SRRating, SRSignature, SRCheckin, ) router = APIRouter(prefix="/api/tasks", tags=["tasks"]) @@ -507,3 +508,301 @@ 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)} +