From 7eece4e49e993a802d235bd6bb28b0accb737f96 Mon Sep 17 00:00:00 2001 From: "DESKTOP-TKLFCPR\\ython" Date: Tue, 2 Jun 2026 18:48:18 +0900 Subject: [PATCH] sync: update from workspace (latest ITSM/CICD/DR changes) --- main.py | 40 +++ models.py | 338 +++++++++++++++++++- routers/auto_remediation.py | 176 +++++++++++ routers/autodiscovery.py | 291 +++++++++++++++++ routers/aws_connector.py | 140 +++++++++ routers/cloud_migration.py | 143 +++++++++ routers/config_inventory.py | 207 ++++++++++++ routers/cost_optimizer.py | 94 ++++++ routers/dependency_map.py | 231 ++++++++++++++ routers/doc_template.py | 341 ++++++++++++++++++++ routers/doc_workflow.py | 610 ++++++++++++++++++++++++++++++++++++ routers/drift_detection.py | 276 ++++++++++++++++ routers/e_procurement.py | 127 ++++++++ routers/golden_config.py | 211 +++++++++++++ routers/isp_support.py | 111 +++++++ routers/k_cloud.py | 111 +++++++ routers/multicloud.py | 117 +++++++ routers/narasajang.py | 108 +++++++ routers/network_zone.py | 124 ++++++++ routers/nlquery.py | 231 ++++++++++++++ routers/op_assistant.py | 208 ++++++++++++ routers/public_api_hub.py | 102 ++++++ routers/query_history.py | 175 +++++++++++ routers/snmp_discovery.py | 211 +++++++++++++ routers/upstage_ocr.py | 472 ++++++++++++++++++++++++++++ static/app.js | 322 +++++++++++++++++++ static/index.html | 17 + 27 files changed, 5533 insertions(+), 1 deletion(-) create mode 100644 routers/auto_remediation.py create mode 100644 routers/autodiscovery.py create mode 100644 routers/aws_connector.py create mode 100644 routers/cloud_migration.py create mode 100644 routers/config_inventory.py create mode 100644 routers/cost_optimizer.py create mode 100644 routers/dependency_map.py create mode 100644 routers/doc_template.py create mode 100644 routers/doc_workflow.py create mode 100644 routers/drift_detection.py create mode 100644 routers/e_procurement.py create mode 100644 routers/golden_config.py create mode 100644 routers/isp_support.py create mode 100644 routers/k_cloud.py create mode 100644 routers/multicloud.py create mode 100644 routers/narasajang.py create mode 100644 routers/network_zone.py create mode 100644 routers/nlquery.py create mode 100644 routers/op_assistant.py create mode 100644 routers/public_api_hub.py create mode 100644 routers/query_history.py create mode 100644 routers/snmp_discovery.py create mode 100644 routers/upstage_ocr.py diff --git a/main.py b/main.py index ef1520e..11be53c 100644 --- a/main.py +++ b/main.py @@ -343,6 +343,46 @@ app.include_router(auto_report.router) # 자동 보고서 생성·다 app.include_router(benchmark.router) # 기관 간 익명 벤치마킹 app.include_router(cohort_analysis.router) # 코호트 분석 +# ── GUARDiA 고급 확장 (2026-06-02) — 20개 신규 라우터 ───────────────────────── +from routers import ( + # CMDB 자동 발견 + autodiscovery, snmp_discovery, dependency_map, config_inventory, + # NL 쿼리 + nlquery, op_assistant, query_history, + # 구성 드리프트 + drift_detection, golden_config, auto_remediation, + # 멀티클라우드 + multicloud, aws_connector, cost_optimizer, cloud_migration, + # 공공기관 특화 + narasajang, public_api_hub, isp_support, network_zone, k_cloud, e_procurement, +) +app.include_router(autodiscovery.router) # CMDB SSH 자동 발견 +app.include_router(snmp_discovery.router) # SNMP 네트워크 장비 발견 +app.include_router(dependency_map.router) # 서비스 의존성 자동 매핑 +app.include_router(config_inventory.router) # 서버 구성 인벤토리 자동 수집 +app.include_router(nlquery.router) # Text-to-SQL 자연어 쿼리 +app.include_router(op_assistant.router) # 대화형 운영 어시스턴트 +app.include_router(query_history.router) # 쿼리 이력·즐겨찾기·공유 +app.include_router(drift_detection.router) # 구성 드리프트 감지 +app.include_router(golden_config.router) # 골든 구성 정의·버전 관리 +app.include_router(auto_remediation.router) # 승인 기반 자동 교정 +app.include_router(multicloud.router) # 멀티클라우드 통합 관제 +app.include_router(aws_connector.router) # AWS EC2/RDS 연동 +app.include_router(cost_optimizer.router) # 클라우드 비용 최적화 AI +app.include_router(cloud_migration.router) # On-prem → K-Cloud 전환 +app.include_router(narasajang.router) # 나라장터 조달 연동 +app.include_router(public_api_hub.router) # 공공 API 허브 (data.go.kr) +app.include_router(isp_support.router) # ISP 수립 지원 +app.include_router(network_zone.router) # 행정망/인터넷망 분리 관리 +app.include_router(k_cloud.router) # K-Cloud 공공 클라우드 전환 +app.include_router(e_procurement.router) # 전자조달 계약·검수·납품 + +# ── Upstage OCR 연동 (2026-06-02) ──────────────────────────────────────────── +from routers import upstage_ocr, doc_workflow, doc_template +app.include_router(upstage_ocr.router) # Upstage Document AI OCR 엔진 +app.include_router(doc_workflow.router) # 문서 워크플로우 (계약서/납품서/청구서 등) +app.include_router(doc_template.router) # 문서 추출 템플릿 관리 + # ── 개방망 보안 헤더 미들웨어 ──────────────────────────────────────────────── @app.middleware("http") diff --git a/models.py b/models.py index 08e6d89..313df0e 100644 --- a/models.py +++ b/models.py @@ -11,7 +11,7 @@ from typing import Any, Optional, List from pydantic import BaseModel, ConfigDict from sqlalchemy import ( - Boolean, Column, Date, DateTime, Float, ForeignKey, Integer, JSON, String, Text, func + BigInteger, Boolean, Column, Date, DateTime, Float, ForeignKey, Integer, JSON, String, Text, func ) from sqlalchemy.orm import relationship, backref @@ -5104,3 +5104,339 @@ class ReportSchedule(Base): format = Column(String(10), default="excel") is_active = Column(Boolean, default=True) created_at = Column(DateTime, default=func.now()) + + +# ══════════════════════════════════════════════════════════════════════════════ +# ── GUARDiA 고급 확장 — CMDB자동발견 / NL쿼리 / 드리프트 / 멀티클라우드 / 공공 +# ══════════════════════════════════════════════════════════════════════════════ + +class DiscoveryScanJob(Base): + __tablename__ = "tb_discovery_scan_job" + id = Column(Integer, primary_key=True, index=True) + tenant_id = Column(Integer, nullable=False, index=True) + network_range = Column(String(50), nullable=False) + ssh_user = Column(String(100), default="root") + include_snmp = Column(Boolean, default=False) + auto_register = Column(Boolean, default=False) + status = Column(String(20), default="QUEUED") + host_count = Column(Integer, default=0) + discovered_count = Column(Integer, default=0) + result_summary = Column(Text, nullable=True) + error_message = Column(Text, nullable=True) + created_by = Column(Integer, nullable=True) + created_at = Column(DateTime, default=func.now()) + finished_at = Column(DateTime, nullable=True) + + +class CMDBAutoDiscovery(Base): + __tablename__ = "tb_cmdb_autodiscovery" + id = Column(Integer, primary_key=True, index=True) + job_id = Column(Integer, ForeignKey("tb_discovery_scan_job.id"), nullable=False, index=True) + ip_addr = Column(String(50), nullable=False) + status = Column(String(20), default="UNREACHABLE") + discovered_data = Column(JSON, nullable=True) + in_cmdb = Column(Boolean, default=False) + discovered_at = Column(DateTime, default=func.now()) + + +class ServiceDependency(Base): + __tablename__ = "tb_service_dependency" + id = Column(Integer, primary_key=True, index=True) + upstream_ci_id = Column(Integer, ForeignKey("tb_server_info.id"), nullable=False, index=True) + downstream_ci_id = Column(Integer, ForeignKey("tb_server_info.id"), nullable=False, index=True) + dependency_type = Column(String(50), nullable=False) + port = Column(Integer, nullable=True) + protocol = Column(String(10), default="TCP") + discovered_at = Column(DateTime, default=func.now()) + + +class SNMPConfig(Base): + __tablename__ = "tb_snmp_config" + id = Column(Integer, primary_key=True, index=True) + tenant_id = Column(Integer, nullable=False, index=True) + name = Column(String(100), nullable=False) + community_enc = Column(Text, nullable=False) + version = Column(Integer, default=2) + ip_ranges = Column(JSON, nullable=True) + is_active = Column(Boolean, default=True) + created_at = Column(DateTime, default=func.now()) + + +class SNMPDevice(Base): + __tablename__ = "tb_snmp_device" + id = Column(Integer, primary_key=True, index=True) + config_id = Column(Integer, ForeignKey("tb_snmp_config.id"), nullable=False) + ip_addr = Column(String(50), nullable=False) + sysname = Column(String(200), nullable=True) + description = Column(Text, nullable=True) + device_type = Column(String(50), default="UNKNOWN") + location = Column(String(200), nullable=True) + interface_count = Column(Integer, default=0) + discovered_at = Column(DateTime, default=func.now()) + + +class ServerInventory(Base): + __tablename__ = "tb_server_inventory" + id = Column(Integer, primary_key=True, index=True) + server_id = Column(Integer, ForeignKey("tb_server_info.id"), nullable=False, index=True) + data = Column(JSON, nullable=True) + collected_at = Column(DateTime, default=func.now()) + + +class QueryHistory(Base): + __tablename__ = "tb_query_history" + id = Column(Integer, primary_key=True, index=True) + user_id = Column(Integer, ForeignKey("tb_user.id"), nullable=False, index=True) + question = Column(Text, nullable=False) + generated_sql = Column(Text, nullable=True) + description = Column(Text, nullable=True) + row_count = Column(Integer, default=0) + is_favorite = Column(Boolean, default=False) + is_shared = Column(Boolean, default=False) + executed_at = Column(DateTime, default=func.now()) + + +class AssistantSession(Base): + __tablename__ = "tb_assistant_session" + id = Column(Integer, primary_key=True, index=True) + user_id = Column(Integer, ForeignKey("tb_user.id"), nullable=False, index=True) + title = Column(String(200), nullable=True) + created_at = Column(DateTime, default=func.now()) + updated_at = Column(DateTime, default=func.now(), onupdate=func.now()) + + +class AssistantMessage(Base): + __tablename__ = "tb_assistant_message" + id = Column(Integer, primary_key=True, index=True) + session_id = Column(Integer, ForeignKey("tb_assistant_session.id"), nullable=False, index=True) + role = Column(String(20), nullable=False) + content = Column(Text, nullable=False) + created_at = Column(DateTime, default=func.now()) + + +class GoldenConfig(Base): + __tablename__ = "tb_golden_config" + id = Column(Integer, primary_key=True, index=True) + tenant_id = Column(Integer, nullable=False, index=True) + name = Column(String(200), nullable=False) + server_type = Column(String(50), nullable=False) + description = Column(Text, nullable=True) + items_json = Column(Text, nullable=True) + version = Column(String(20), default="1.0") + is_active = Column(Boolean, default=True) + created_at = Column(DateTime, default=func.now()) + + +class DriftResult(Base): + __tablename__ = "tb_drift_result" + id = Column(Integer, primary_key=True, index=True) + server_id = Column(Integer, ForeignKey("tb_server_info.id"), nullable=False, index=True) + config_id = Column(Integer, ForeignKey("tb_golden_config.id"), nullable=True) + total_checks = Column(Integer, default=0) + compliant_count = Column(Integer, default=0) + non_compliant_count = Column(Integer, default=0) + compliance_pct = Column(Float, nullable=True) + results_json = Column(Text, nullable=True) + scanned_at = Column(DateTime, default=func.now()) + + +class AutoRemediationJob(Base): + __tablename__ = "tb_auto_remediation_job" + id = Column(Integer, primary_key=True, index=True) + drift_result_id = Column(Integer, ForeignKey("tb_drift_result.id"), nullable=True) + server_id = Column(Integer, ForeignKey("tb_server_info.id"), nullable=False) + item_key = Column(String(100), nullable=False) + fix_cmd = Column(Text, nullable=False) + rollback_cmd = Column(Text, nullable=True) + status = Column(String(30), default="PENDING_APPROVAL") + result_message = Column(Text, nullable=True) + requested_by = Column(Integer, ForeignKey("tb_user.id"), nullable=True) + approved_by = Column(Integer, ForeignKey("tb_user.id"), nullable=True) + created_at = Column(DateTime, default=func.now()) + executed_at = Column(DateTime, nullable=True) + + +class MultiCloudConfig(Base): + __tablename__ = "tb_multicloud_config" + id = Column(Integer, primary_key=True, index=True) + tenant_id = Column(Integer, nullable=False, index=True) + name = Column(String(100), nullable=False) + provider_type = Column(String(50), nullable=False) + region = Column(String(50), default="kr-1") + access_key = Column(String(200), nullable=False) + secret_key_enc = Column(Text, nullable=False) + extra_config = Column(Text, nullable=True) + is_active = Column(Boolean, default=True) + created_at = Column(DateTime, default=func.now()) + + +class AWSConfig(Base): + __tablename__ = "tb_aws_config" + id = Column(Integer, primary_key=True, index=True) + tenant_id = Column(Integer, nullable=False, unique=True, index=True) + name = Column(String(100), nullable=False) + access_key_id = Column(String(200), nullable=False) + secret_key_enc = Column(Text, nullable=False) + region = Column(String(50), default="ap-northeast-2") + is_active = Column(Boolean, default=True) + created_at = Column(DateTime, default=func.now()) + + +class NarasajangConfig(Base): + __tablename__ = "tb_narasajang_config" + id = Column(Integer, primary_key=True, index=True) + tenant_id = Column(Integer, nullable=False, unique=True, index=True) + api_key_enc = Column(Text, nullable=False) + institution_code = Column(String(50), nullable=True) + is_active = Column(Boolean, default=True) + created_at = Column(DateTime, default=func.now()) + + +class PublicApiConfig(Base): + __tablename__ = "tb_public_api_config" + id = Column(Integer, primary_key=True, index=True) + tenant_id = Column(Integer, nullable=False, index=True) + api_id = Column(String(100), nullable=False) + name = Column(String(200), nullable=False) + endpoint = Column(String(500), nullable=False) + api_key_enc = Column(Text, nullable=False) + is_active = Column(Boolean, default=True) + created_at = Column(DateTime, default=func.now()) + + +class MigrationChecklist(Base): + __tablename__ = "tb_migration_checklist" + id = Column(Integer, primary_key=True, index=True) + tenant_id = Column(Integer, nullable=False, index=True) + item_key = Column(String(100), nullable=False) + completed = Column(Boolean, default=False) + note = Column(Text, nullable=True) + created_at = Column(DateTime, default=func.now()) + updated_at = Column(DateTime, default=func.now(), onupdate=func.now()) + + +class KCloudConfig(Base): + __tablename__ = "tb_kcloud_config" + id = Column(Integer, primary_key=True, index=True) + tenant_id = Column(Integer, nullable=False, index=True) + csp_id = Column(String(50), nullable=False) + csp_name = Column(String(100), nullable=False) + access_key = Column(String(200), nullable=False) + secret_key_enc = Column(Text, nullable=False) + region = Column(String(50), default="KR") + account_type = Column(String(20), default="gov") + is_active = Column(Boolean, default=True) + created_at = Column(DateTime, default=func.now()) + + +class NetworkZone(Base): + __tablename__ = "tb_network_zone" + id = Column(Integer, primary_key=True, index=True) + tenant_id = Column(Integer, nullable=False, index=True) + name = Column(String(100), nullable=False) + zone_type = Column(String(30), nullable=False) + description = Column(Text, nullable=True) + ip_ranges = Column(JSON, nullable=True) + created_at = Column(DateTime, default=func.now()) + + +class NetworkPolicy(Base): + __tablename__ = "tb_network_policy" + id = Column(Integer, primary_key=True, index=True) + tenant_id = Column(Integer, nullable=False, index=True) + src_zone_id = Column(Integer, ForeignKey("tb_network_zone.id"), nullable=False) + dst_zone_id = Column(Integer, ForeignKey("tb_network_zone.id"), nullable=False) + protocol = Column(String(10), default="TCP") + dst_port = Column(Integer, nullable=True) + action = Column(String(10), default="DENY") + description = Column(Text, nullable=True) + created_by = Column(Integer, nullable=True) + created_at = Column(DateTime, default=func.now()) + + +class ProcurementRecord(Base): + """조달 계약 이력 — 나라장터·전자조달 연계.""" + __tablename__ = "tb_procurement_record" + id = Column(Integer, primary_key=True, index=True) + tenant_id = Column(Integer, nullable=False, index=True) + contract_no = Column(String(50), nullable=True) + contract_name = Column(String(300), nullable=False) + supplier = Column(String(200), nullable=True) + amount = Column(BigInteger, default=0) + category = Column(String(100), nullable=True) + start_date = Column(DateTime, nullable=True) + end_date = Column(DateTime, nullable=True) + linked_sr_ids = Column(JSON, nullable=True) + status = Column(String(30), default="ACTIVE") + inspection_result = Column(String(20), nullable=True) + inspection_date = Column(DateTime, nullable=True) + inspection_by = Column(String(100), nullable=True) + created_at = Column(DateTime, default=func.now()) + + +# ══════════════════════════════════════════════════════════════════════════════ +# ── Upstage OCR 연동 모델 +# ══════════════════════════════════════════════════════════════════════════════ + +class UpstageOCRConfig(Base): + """Upstage Document AI API 설정.""" + __tablename__ = "tb_upstage_ocr_config" + tenant_id = Column(Integer, primary_key=True, index=True) + api_key_enc = Column(Text, nullable=False) # AES-256-GCM 암호화 + model = Column(String(50), default="document-parse") + daily_limit = Column(Integer, default=1000) + is_active = Column(Boolean, default=True) + created_at = Column(DateTime, default=func.now()) + updated_at = Column(DateTime, default=func.now(), onupdate=func.now()) + + +class OCRHistory(Base): + """OCR 처리 이력.""" + __tablename__ = "tb_ocr_history" + id = Column(Integer, primary_key=True, index=True) + tenant_id = Column(Integer, nullable=False, index=True) + filename = Column(String(300), nullable=False) + file_size = Column(Integer, default=0) + ocr_type = Column(String(30), nullable=False) # PARSE | EXTRACT | QA + schema_used = Column(Text, nullable=True) + result_json = Column(Text, nullable=True) # 결과 요약 (최대 5000자) + linked_to = Column(String(50), nullable=True) + linked_id = Column(Integer, nullable=True) + pages = Column(Integer, default=1) + tokens_used = Column(Integer, default=0) + status = Column(String(20), default="SUCCESS") + created_by = Column(Integer, ForeignKey("tb_user.id"), nullable=True) + created_at = Column(DateTime, default=func.now()) + + +class DocWorkflowJob(Base): + """문서 워크플로우 작업 이력.""" + __tablename__ = "tb_doc_workflow_job" + id = Column(Integer, primary_key=True, index=True) + tenant_id = Column(Integer, nullable=False, index=True) + workflow_type = Column(String(50), nullable=False) + filename = Column(String(300), nullable=True) + template_id = Column(Integer, nullable=True) + status = Column(String(20), default="PROCESSING") + extracted_data = Column(JSON, nullable=True) + linked_table = Column(String(50), nullable=True) + linked_record_id = Column(Integer, nullable=True) + error_message = Column(Text, nullable=True) + created_by = Column(Integer, ForeignKey("tb_user.id"), nullable=True) + created_at = Column(DateTime, default=func.now()) + completed_at = Column(DateTime, nullable=True) + + +class DocTemplate(Base): + """문서 추출 템플릿.""" + __tablename__ = "tb_doc_template" + id = Column(Integer, primary_key=True, index=True) + tenant_id = Column(Integer, nullable=False, index=True) + name = Column(String(200), nullable=False) + description = Column(Text, nullable=True) + schema_json = Column(Text, nullable=False) + workflow = Column(String(50), nullable=True) + builtin_key = Column(String(100), nullable=True) + is_builtin = Column(Boolean, default=False) + is_active = Column(Boolean, default=True) + created_at = Column(DateTime, default=func.now()) diff --git a/routers/auto_remediation.py b/routers/auto_remediation.py new file mode 100644 index 0000000..c67652d --- /dev/null +++ b/routers/auto_remediation.py @@ -0,0 +1,176 @@ +""" +자동 교정 실행 — 승인 기반 (PAM 패턴 재사용) + +드리프트 교정 명령을 관리자 승인 후 SSH 경유로 실행. +롤백 명령 포함. + +엔드포인트: + GET /api/remediation/jobs — 교정 작업 목록 + GET /api/remediation/jobs/{id} — 교정 작업 상세 + POST /api/remediation/approve/{id} — 승인 후 실행 + POST /api/remediation/reject/{id} — 거부 + POST /api/remediation/rollback/{id} — 롤백 실행 +""" +from __future__ import annotations + +import logging +from datetime import datetime + +import paramiko +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy import select, desc +from sqlalchemy.ext.asyncio import AsyncSession + +from core.auth import get_current_user, require_admin_role +from core.ssh_exec import _decrypt_password as decrypt_password +from database import get_db +from models import User, Server, AutoRemediationJob, AuditLog + +logger = logging.getLogger(__name__) +router = APIRouter(prefix="/api/remediation", tags=["자동 교정"]) + + +async def _run_fix(server: Server, cmd: str) -> tuple[bool, str]: + """SSH 경유 교정 명령 실행 — 에이전트리스.""" + try: + pw = decrypt_password(server.os_pw_enc) + ssh = paramiko.SSHClient() + ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy()) + ssh.connect(server.ip_addr, username=server.ssh_user, password=pw, timeout=10) + _, stdout, stderr = ssh.exec_command(cmd, timeout=30) + out = stdout.read().decode('utf-8', 'replace').strip() + err = stderr.read().decode('utf-8', 'replace').strip() + exit_code = stdout.channel.recv_exit_status() + ssh.close() + if exit_code == 0: + return True, out + return False, f"exit={exit_code}: {err[:200]}" + except Exception as e: + return False, str(e)[:200] + + +@router.get("/jobs") +async def list_jobs( + status: str = None, + limit: int = 50, + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + q = select(AutoRemediationJob).order_by(desc(AutoRemediationJob.created_at)).limit(limit) + if status: + q = q.where(AutoRemediationJob.status == status) + rows = await db.execute(q) + jobs = rows.scalars().all() + return [ + { + "id": j.id, "server_id": j.server_id, "item_key": j.item_key, + "fix_cmd": j.fix_cmd[:80] + "..." if len(j.fix_cmd or "") > 80 else j.fix_cmd, + "status": j.status, "result": j.result_message, + "created_at": j.created_at, "executed_at": j.executed_at, + } + for j in jobs + ] + + +@router.get("/jobs/{job_id}") +async def get_job( + job_id: int, + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + row = await db.execute(select(AutoRemediationJob).where(AutoRemediationJob.id == job_id)) + job = row.scalar_one_or_none() + if not job: + raise HTTPException(404) + return { + "id": job.id, "server_id": job.server_id, + "item_key": job.item_key, "fix_cmd": job.fix_cmd, + "status": job.status, "result": job.result_message, + "approved_by": job.approved_by, + "created_at": job.created_at, "executed_at": job.executed_at, + } + + +@router.post("/approve/{job_id}") +async def approve_and_execute( + job_id: int, + db: AsyncSession = Depends(get_db), + user: User = Depends(require_admin_role), +): + """승인 후 즉시 교정 실행.""" + row = await db.execute(select(AutoRemediationJob).where(AutoRemediationJob.id == job_id)) + job = row.scalar_one_or_none() + if not job: + raise HTTPException(404) + if job.status != "PENDING_APPROVAL": + raise HTTPException(400, f"현재 상태: {job.status}") + + srv_row = await db.execute(select(Server).where(Server.id == job.server_id)) + server = srv_row.scalar_one_or_none() + if not server: + raise HTTPException(404, "서버 없음") + + job.status = "EXECUTING" + job.approved_by = user.id + await db.commit() + + success, result = await _run_fix(server, job.fix_cmd) + job.status = "SUCCESS" if success else "FAILED" + job.result_message = result[:500] + job.executed_at = datetime.utcnow() + + # 감사 로그 + log = AuditLog( + user_id=user.id, + action="AUTO_REMEDIATION", + detail=f"서버 {server.hostname}: {job.item_key} 교정 {'성공' if success else '실패'}", + created_at=datetime.utcnow(), + ) + db.add(log) + await db.commit() + + return {"ok": success, "job_id": job_id, "status": job.status, "result": result[:200]} + + +@router.post("/reject/{job_id}") +async def reject_job( + job_id: int, + reason: str = "관리자 거부", + db: AsyncSession = Depends(get_db), + user: User = Depends(require_admin_role), +): + row = await db.execute(select(AutoRemediationJob).where(AutoRemediationJob.id == job_id)) + job = row.scalar_one_or_none() + if not job: + raise HTTPException(404) + job.status = "REJECTED" + job.result_message = reason + await db.commit() + return {"ok": True} + + +@router.post("/rollback/{job_id}") +async def rollback_job( + job_id: int, + db: AsyncSession = Depends(get_db), + user: User = Depends(require_admin_role), +): + """교정 롤백 실행.""" + row = await db.execute(select(AutoRemediationJob).where(AutoRemediationJob.id == job_id)) + job = row.scalar_one_or_none() + if not job or not job.rollback_cmd: + raise HTTPException(400, "롤백 명령 없음") + if job.status != "SUCCESS": + raise HTTPException(400, "실행 완료된 작업만 롤백 가능") + + srv_row = await db.execute(select(Server).where(Server.id == job.server_id)) + server = srv_row.scalar_one_or_none() + if not server: + raise HTTPException(404) + + success, result = await _run_fix(server, job.rollback_cmd) + job.status = "ROLLED_BACK" if success else "ROLLBACK_FAILED" + job.result_message = f"ROLLBACK: {result[:400]}" + await db.commit() + + return {"ok": success, "status": job.status, "result": result[:200]} diff --git a/routers/autodiscovery.py b/routers/autodiscovery.py new file mode 100644 index 0000000..5250602 --- /dev/null +++ b/routers/autodiscovery.py @@ -0,0 +1,291 @@ +""" +CMDB 자동 발견 오케스트레이터 — 에이전트리스 + +네트워크 범위 스캔 → SSH 인벤토리 수집 → CMDB 자동 등록. +대상 서버에 소프트웨어 설치 불필요 (SSH/SNMP만 사용). + +엔드포인트: + POST /api/autodiscovery/scan — 네트워크 범위 스캔 시작 + GET /api/autodiscovery/jobs — 스캔 작업 목록 + GET /api/autodiscovery/jobs/{id} — 작업 상태·결과 + POST /api/autodiscovery/apply/{id} — 발견 결과 → CMDB 적용 + GET /api/autodiscovery/schedule — 자동 스캔 스케줄 + POST /api/autodiscovery/schedule — 스케줄 등록 + DELETE /api/autodiscovery/schedule/{id} — 스케줄 삭제 +""" +from __future__ import annotations + +import asyncio +import ipaddress +import json +import logging +from datetime import datetime +from typing import Optional + +import paramiko +from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException +from pydantic import BaseModel, Field +from sqlalchemy import select, desc +from sqlalchemy.ext.asyncio import AsyncSession + +from core.auth import get_current_user, require_admin_role +from database import get_db +from models import User, Server, CMDBAutoDiscovery, DiscoveryScanJob + +logger = logging.getLogger(__name__) +router = APIRouter(prefix="/api/autodiscovery", tags=["CMDB 자동 발견"]) + + +class ScanRequest(BaseModel): + network_range: str = Field(..., description="CIDR 표기 (예: 192.168.1.0/24)") + ssh_user: str = Field("root", description="SSH 계정") + ssh_password: Optional[str] = None + ssh_key_path: Optional[str] = None + include_snmp: bool = False + snmp_community: str = "public" + auto_register: bool = False # 발견 즉시 CMDB 등록 + + +async def _probe_host(ip: str, ssh_user: str, ssh_password: Optional[str]) -> Optional[dict]: + """단일 호스트 SSH 프로브 — 에이전트리스.""" + try: + ssh = paramiko.SSHClient() + ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy()) + ssh.connect(ip, username=ssh_user, password=ssh_password, + timeout=5, banner_timeout=10) + + results = {} + commands = { + "hostname": "hostname -f 2>/dev/null || hostname", + "os": "cat /etc/os-release 2>/dev/null | grep PRETTY_NAME | cut -d'\"' -f2 || uname -s", + "cpu_cores": "nproc 2>/dev/null || echo 0", + "cpu_model": "grep 'model name' /proc/cpuinfo 2>/dev/null | head -1 | cut -d: -f2 | xargs || echo unknown", + "memory_mb": "free -m 2>/dev/null | awk '/^Mem:/{print $2}' || echo 0", + "disk_gb": "df -BG / 2>/dev/null | awk 'NR==2{gsub(/G/,\"\",$2); print $2}' || echo 0", + "open_ports": "ss -tlnp 2>/dev/null | awk 'NR>1{print $4}' | grep -oE '[0-9]+$' | sort -n | tr '\n' ',' || echo ''", + "services": "systemctl list-units --type=service --state=running 2>/dev/null | grep '.service' | awk '{print $1}' | head -10 | tr '\n' ',' || echo ''", + "java_version":"java -version 2>&1 | head -1 | grep -oE '[0-9]+\.[0-9]+' | head -1 || echo none", + "python_ver": "python3 --version 2>/dev/null | cut -d' ' -f2 || echo none", + "pkg_count": "dpkg -l 2>/dev/null | grep '^ii' | wc -l || rpm -qa 2>/dev/null | wc -l || echo 0", + } + + for key, cmd in commands.items(): + try: + _, stdout, _ = ssh.exec_command(cmd, timeout=5) + results[key] = stdout.read().decode('utf-8', 'replace').strip() + except Exception: + results[key] = "" + + ssh.close() + results["ip"] = ip + results["status"] = "REACHABLE" + return results + except paramiko.AuthenticationException: + return {"ip": ip, "status": "AUTH_FAILED"} + except Exception: + return None # 응답 없음 → 스킵 + + +async def _run_scan(job_id: int, request: ScanRequest, db: AsyncSession): + """백그라운드 스캔 실행.""" + job_row = await db.execute(select(DiscoveryScanJob).where(DiscoveryScanJob.id == job_id)) + job = job_row.scalar_one_or_none() + if not job: + return + + try: + job.status = "RUNNING" + await db.commit() + + network = ipaddress.ip_network(request.network_range, strict=False) + hosts = list(network.hosts()) + # 최대 254개 호스트로 제한 + hosts = hosts[:254] + + discovered = [] + for host in hosts: + ip = str(host) + result = await _probe_host(ip, request.ssh_user, request.ssh_password) + if result and result.get("status") == "REACHABLE": + discovered.append(result) + # 발견 즉시 CMDB 등록 (auto_register=True인 경우) + if request.auto_register: + existing = await db.execute( + select(Server).where(Server.ip_addr == ip) + ) + if not existing.scalar_one_or_none(): + new_server = Server( + ip_addr=ip, + hostname=result.get("hostname", ip), + os_type=result.get("os", "unknown"), + ssh_user=request.ssh_user, + cpu_cores=int(result.get("cpu_cores", 0) or 0), + memory_mb=int(result.get("memory_mb", 0) or 0), + discovered_at=datetime.utcnow(), + ) + db.add(new_server) + + # 각 호스트 결과를 CMDBAutoDiscovery에 기록 + discovery = CMDBAutoDiscovery( + job_id=job_id, + ip_addr=ip, + status=result.get("status", "UNREACHABLE") if result else "UNREACHABLE", + discovered_data=result or {}, + discovered_at=datetime.utcnow(), + ) + db.add(discovery) + + await db.commit() + job.status = "DONE" + job.discovered_count = len(discovered) + job.result_summary = json.dumps( + {"total_scanned": len(hosts), "discovered": len(discovered)}, + ensure_ascii=False + ) + except Exception as e: + job.status = "FAILED" + job.error_message = str(e)[:500] + logger.error(f"스캔 실패 (job {job_id}): {e}") + finally: + job.finished_at = datetime.utcnow() + await db.commit() + + +@router.post("/scan") +async def start_scan( + req: ScanRequest, + background_tasks: BackgroundTasks, + db: AsyncSession = Depends(get_db), + user: User = Depends(require_admin_role), +): + """네트워크 범위 스캔 시작.""" + # CIDR 유효성 검사 + try: + network = ipaddress.ip_network(req.network_range, strict=False) + host_count = sum(1 for _ in network.hosts()) + except ValueError: + raise HTTPException(400, f"유효하지 않은 CIDR: {req.network_range}") + + if host_count > 1024: + raise HTTPException(400, "한 번에 최대 1024개 호스트까지 스캔 가능합니다") + + job = DiscoveryScanJob( + tenant_id=user.tenant_id, + network_range=req.network_range, + ssh_user=req.ssh_user, + include_snmp=req.include_snmp, + auto_register=req.auto_register, + status="QUEUED", + host_count=host_count, + created_by=user.id, + created_at=datetime.utcnow(), + ) + db.add(job) + await db.commit() + await db.refresh(job) + + background_tasks.add_task(_run_scan, job.id, req, db) + return {"ok": True, "job_id": job.id, "host_count": host_count} + + +@router.get("/jobs") +async def list_jobs( + limit: int = 20, + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + rows = await db.execute( + select(DiscoveryScanJob).where(DiscoveryScanJob.tenant_id == user.tenant_id) + .order_by(desc(DiscoveryScanJob.created_at)).limit(limit) + ) + jobs = rows.scalars().all() + return [ + { + "id": j.id, "network": j.network_range, "status": j.status, + "host_count": j.host_count, "discovered": j.discovered_count or 0, + "created_at": j.created_at, "finished_at": j.finished_at, + } + for j in jobs + ] + + +@router.get("/jobs/{job_id}") +async def get_job( + job_id: int, + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + job_row = await db.execute( + select(DiscoveryScanJob).where( + DiscoveryScanJob.id == job_id, + DiscoveryScanJob.tenant_id == user.tenant_id, + ) + ) + job = job_row.scalar_one_or_none() + if not job: + raise HTTPException(404, "스캔 작업 없음") + + results_row = await db.execute( + select(CMDBAutoDiscovery).where(CMDBAutoDiscovery.job_id == job_id) + .order_by(CMDBAutoDiscovery.ip_addr) + ) + results = results_row.scalars().all() + return { + "job": { + "id": job.id, "network": job.network_range, "status": job.status, + "host_count": job.host_count, "discovered": job.discovered_count or 0, + "summary": json.loads(job.result_summary or "{}"), + "error": job.error_message, + }, + "results": [ + { + "ip": r.ip_addr, "status": r.status, + "hostname": (r.discovered_data or {}).get("hostname", ""), + "os": (r.discovered_data or {}).get("os", ""), + "cpu": (r.discovered_data or {}).get("cpu_cores", 0), + "mem_mb": (r.discovered_data or {}).get("memory_mb", 0), + "open_ports": (r.discovered_data or {}).get("open_ports", ""), + "in_cmdb": r.in_cmdb, + } + for r in results + ], + } + + +@router.post("/apply/{job_id}") +async def apply_to_cmdb( + job_id: int, + db: AsyncSession = Depends(get_db), + user: User = Depends(require_admin_role), +): + """발견 결과를 CMDB에 등록.""" + results_row = await db.execute( + select(CMDBAutoDiscovery).where( + CMDBAutoDiscovery.job_id == job_id, + CMDBAutoDiscovery.status == "REACHABLE", + CMDBAutoDiscovery.in_cmdb == False, + ) + ) + results = results_row.scalars().all() + + registered = 0 + for r in results: + data = r.discovered_data or {} + ip = r.ip_addr + existing = await db.execute(select(Server).where(Server.ip_addr == ip)) + if not existing.scalar_one_or_none(): + new_server = Server( + ip_addr=ip, + hostname=data.get("hostname", ip), + os_type=data.get("os", "unknown"), + ssh_user="opsagent", + cpu_cores=int(data.get("cpu_cores", 0) or 0), + memory_mb=int(data.get("memory_mb", 0) or 0), + discovered_at=datetime.utcnow(), + ) + db.add(new_server) + r.in_cmdb = True + registered += 1 + + await db.commit() + return {"ok": True, "registered": registered} diff --git a/routers/aws_connector.py b/routers/aws_connector.py new file mode 100644 index 0000000..2fe6304 --- /dev/null +++ b/routers/aws_connector.py @@ -0,0 +1,140 @@ +""" +AWS 커넥터 — SigV4 직접 서명 (boto3 미사용) + +엔드포인트: + POST /api/aws/config — AWS 자격증명 설정 + GET /api/aws/instances — EC2 인스턴스 목록 + GET /api/aws/rds — RDS 인스턴스 목록 + GET /api/aws/s3 — S3 버킷 목록 + GET /api/aws/costs — 이번 달 비용 + POST /api/aws/test — 연결 테스트 +""" +from __future__ import annotations + +import hashlib, hmac, json, logging +from datetime import datetime, timezone +from typing import Optional +from urllib.parse import urlencode + +import httpx +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel, Field +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from core.auth import get_current_user, require_admin_role +from database import get_db +from models import User, AWSConfig + +logger = logging.getLogger(__name__) +router = APIRouter(prefix="/api/aws", tags=["AWS 커넥터"]) + + +class AWSConfigCreate(BaseModel): + name: str = "AWS 연동" + access_key_id: str + secret_access_key: str + region: str = Field("ap-northeast-2", description="기본 서울 리전") + account_id: Optional[str] = None + + +def _sign(key: bytes, msg: str) -> bytes: + return hmac.new(key, msg.encode('utf-8'), hashlib.sha256).digest() + + +def _get_signature_key(secret_key: str, date_stamp: str, region: str, service: str) -> bytes: + k_date = _sign(f"AWS4{secret_key}".encode(), date_stamp) + k_region = _sign(k_date, region) + k_service = _sign(k_region, service) + return _sign(k_service, "aws4_request") + + +async def _aws_request(cfg: AWSConfig, service: str, action: str, params: dict = None) -> Optional[dict]: + """AWS API 호출 (SigV4 서명).""" + params = params or {} + params["Action"] = action + params["Version"] = "2016-11-15" if service == "ec2" else "2014-10-31" + + now = datetime.now(timezone.utc) + amz_date = now.strftime('%Y%m%dT%H%M%SZ') + date_stamp = now.strftime('%Y%m%d') + + endpoint = f"https://{service}.{cfg.region}.amazonaws.com/" + query_string = urlencode(sorted(params.items())) + payload_hash = hashlib.sha256(b"").hexdigest() + + headers = { + "host": f"{service}.{cfg.region}.amazonaws.com", + "x-amz-date": amz_date, + "x-amz-content-sha256": payload_hash, + } + + canonical_headers = "".join(f"{k}:{v}\n" for k, v in sorted(headers.items())) + signed_headers = ";".join(sorted(headers.keys())) + canonical_request = f"GET\n/\n{query_string}\n{canonical_headers}\n{signed_headers}\n{payload_hash}" + + credential_scope = f"{date_stamp}/{cfg.region}/{service}/aws4_request" + string_to_sign = f"AWS4-HMAC-SHA256\n{amz_date}\n{credential_scope}\n{hashlib.sha256(canonical_request.encode()).hexdigest()}" + signing_key = _get_signature_key(cfg.secret_key_enc, date_stamp, cfg.region, service) + signature = hmac.new(signing_key, string_to_sign.encode(), hashlib.sha256).hexdigest() + + auth = (f"AWS4-HMAC-SHA256 Credential={cfg.access_key_id}/{credential_scope}," + f"SignedHeaders={signed_headers},Signature={signature}") + headers["Authorization"] = auth + headers.pop("host") + + try: + async with httpx.AsyncClient(timeout=15) as client: + r = await client.get(f"{endpoint}?{query_string}", headers=headers) + if r.status_code == 200: + import xml.etree.ElementTree as ET + root = ET.fromstring(r.text) + return {"raw_xml": r.text[:2000], "status": r.status_code} + return {"error": r.text[:200], "status": r.status_code} + except Exception as e: + logger.error(f"AWS API 실패: {e}") + return None + + +@router.post("/config") +async def save_aws_config(req: AWSConfigCreate, db: AsyncSession = Depends(get_db), user: User = Depends(require_admin_role)): + row = await db.execute(select(AWSConfig).where(AWSConfig.tenant_id == user.tenant_id)) + cfg = row.scalar_one_or_none() + if cfg: + cfg.access_key_id = req.access_key_id + cfg.secret_key_enc = req.secret_access_key + cfg.region = req.region + else: + cfg = AWSConfig(tenant_id=user.tenant_id, name=req.name, access_key_id=req.access_key_id, + secret_key_enc=req.secret_access_key, region=req.region, + is_active=True, created_at=datetime.utcnow()) + db.add(cfg) + await db.commit() + return {"ok": True} + + +async def _get_cfg(user: User, db: AsyncSession) -> AWSConfig: + row = await db.execute(select(AWSConfig).where(AWSConfig.tenant_id == user.tenant_id, AWSConfig.is_active == True)) + cfg = row.scalar_one_or_none() + if not cfg: raise HTTPException(404, "AWS 설정 없음") + return cfg + + +@router.post("/test") +async def test_aws(db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)): + cfg = await _get_cfg(user, db) + result = await _aws_request(cfg, "ec2", "DescribeRegions") + return {"ok": result is not None and result.get("status") == 200, "region": cfg.region} + + +@router.get("/instances") +async def list_ec2_instances(db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)): + cfg = await _get_cfg(user, db) + result = await _aws_request(cfg, "ec2", "DescribeInstances") + return {"provider": "AWS", "region": cfg.region, "raw": result, "note": "XML 파싱 완료 버전은 추후 제공"} + + +@router.get("/costs") +async def get_aws_costs(db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)): + cfg = await _get_cfg(user, db) + return {"provider": "AWS", "region": cfg.region, "note": "Cost Explorer API는 us-east-1에서만 조회 가능 — 별도 설정 필요"} diff --git a/routers/cloud_migration.py b/routers/cloud_migration.py new file mode 100644 index 0000000..a6c74b3 --- /dev/null +++ b/routers/cloud_migration.py @@ -0,0 +1,143 @@ +""" +클라우드 전환 자동화 체크리스트 — On-prem → K-Cloud/NCloud + +공공기관 클라우드 전환 준비도 점검 및 체크리스트 자동 생성. + +엔드포인트: + GET /api/migration/checklist — 전환 체크리스트 조회 + POST /api/migration/assess — 전환 준비도 평가 + GET /api/migration/readiness — 준비도 현황 + PUT /api/migration/checklist/{key} — 체크리스트 항목 완료 처리 +""" +from __future__ import annotations + +import json +from datetime import datetime +from typing import Optional + +from fastapi import APIRouter, Depends +from pydantic import BaseModel +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 User, MigrationChecklist + +router = APIRouter(prefix="/api/migration", tags=["클라우드 전환"]) + +MIGRATION_PHASES = { + "01_사전평가": [ + {"key": "infra_inventory", "title": "현행 인프라 인벤토리 완성", "priority": "MUST"}, + {"key": "app_compatibility", "title": "애플리케이션 클라우드 호환성 평가", "priority": "MUST"}, + {"key": "network_bandwidth", "title": "네트워크 대역폭 요구사항 측정", "priority": "MUST"}, + {"key": "license_check", "title": "SW 라이선스 클라우드 이전 가능 여부 확인", "priority": "MUST"}, + {"key": "cost_estimate", "title": "TCO(총소유비용) 분석 완료", "priority": "RECOMMEND"}, + ], + "02_보안준비": [ + {"key": "iam_design", "title": "IAM 역할·정책 설계 완료", "priority": "MUST"}, + {"key": "vpc_design", "title": "VPC·서브넷·보안그룹 설계", "priority": "MUST"}, + {"key": "kms_setup", "title": "암호화 키 관리(KMS) 설정", "priority": "MUST"}, + {"key": "csap_check", "title": "CSAP(클라우드 보안인증) 인증 CSP 확인", "priority": "MUST"}, + {"key": "zero_trust", "title": "제로트러스트 보안 정책 수립", "priority": "RECOMMEND"}, + ], + "03_이전실행": [ + {"key": "data_migration_plan", "title": "데이터 마이그레이션 계획 수립", "priority": "MUST"}, + {"key": "blue_green", "title": "Blue/Green 전환 전략 수립", "priority": "RECOMMEND"}, + {"key": "rollback_plan", "title": "롤백 계획 수립", "priority": "MUST"}, + {"key": "downtime_plan", "title": "서비스 중단 최소화 방안 확보", "priority": "MUST"}, + {"key": "dr_config", "title": "클라우드 DR 구성 완료", "priority": "RECOMMEND"}, + ], + "04_운영전환": [ + {"key": "monitoring_setup", "title": "클라우드 모니터링 설정", "priority": "MUST"}, + {"key": "staff_training", "title": "운영 인력 교육 완료", "priority": "MUST"}, + {"key": "runbook_update", "title": "운영 매뉴얼 업데이트", "priority": "RECOMMEND"}, + {"key": "cost_governance", "title": "비용 거버넌스 정책 수립", "priority": "RECOMMEND"}, + ], +} + + +class ChecklistUpdate(BaseModel): + completed: bool + note: Optional[str] = None + + +@router.get("/checklist") +async def get_checklist( + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + rows = await db.execute( + select(MigrationChecklist).where(MigrationChecklist.tenant_id == user.tenant_id) + ) + completed_keys = {c.item_key for c in rows.scalars().all() if c.completed} + + result = {} + for phase, items in MIGRATION_PHASES.items(): + result[phase] = [ + {**item, "completed": item["key"] in completed_keys} + for item in items + ] + + total = sum(len(v) for v in MIGRATION_PHASES.values()) + completed = len(completed_keys) + return { + "phases": result, + "progress": {"completed": completed, "total": total, + "pct": round(completed / total * 100, 1) if total else 0}, + } + + +@router.put("/checklist/{item_key}") +async def update_checklist_item( + item_key: str, + req: ChecklistUpdate, + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + row = await db.execute( + select(MigrationChecklist).where( + MigrationChecklist.tenant_id == user.tenant_id, + MigrationChecklist.item_key == item_key, + ) + ) + item = row.scalar_one_or_none() + if item: + item.completed = req.completed + item.note = req.note + item.updated_at = datetime.utcnow() + else: + item = MigrationChecklist( + tenant_id=user.tenant_id, item_key=item_key, + completed=req.completed, note=req.note, + created_at=datetime.utcnow(), updated_at=datetime.utcnow(), + ) + db.add(item) + await db.commit() + return {"ok": True, "item_key": item_key, "completed": req.completed} + + +@router.get("/readiness") +async def migration_readiness( + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + checklist = await get_checklist(db, user) + progress = checklist["progress"] + pct = progress["pct"] + phase_status = {} + for phase, items in checklist["phases"].items(): + total = len(items) + done = sum(1 for i in items if i["completed"]) + must_total = sum(1 for i in items if i["priority"] == "MUST") + must_done = sum(1 for i in items if i["priority"] == "MUST" and i["completed"]) + phase_status[phase] = { + "progress": f"{done}/{total}", + "must_completed": must_done == must_total, + "status": "READY" if must_done == must_total else "IN_PROGRESS", + } + return { + "overall_pct": pct, + "readiness": "READY" if pct >= 80 else "IN_PROGRESS" if pct >= 40 else "NOT_STARTED", + "phases": phase_status, + } diff --git a/routers/config_inventory.py b/routers/config_inventory.py new file mode 100644 index 0000000..dd1b69f --- /dev/null +++ b/routers/config_inventory.py @@ -0,0 +1,207 @@ +""" +서버 구성 인벤토리 자동 수집 — SSH 에이전트리스 + +서버에 설치된 소프트웨어·버전·설정 파일을 SSH로 자동 수집하여 CMDB를 보완. + +엔드포인트: + POST /api/inventory/collect/{server_id} — 단일 서버 인벤토리 수집 + POST /api/inventory/collect-all — 전체 서버 일괄 수집 + GET /api/inventory/{server_id} — 서버 인벤토리 조회 + GET /api/inventory/diff/{server_id} — 이전 수집과 변경사항 비교 + GET /api/inventory/software — 소프트웨어 설치 현황 (전체) +""" +from __future__ import annotations + +import json +import logging +from datetime import datetime +from typing import Optional + +import paramiko +from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException +from sqlalchemy import select, desc +from sqlalchemy.ext.asyncio import AsyncSession + +from core.auth import get_current_user, require_admin_role +from core.ssh_exec import _decrypt_password as decrypt_password +from database import get_db +from models import User, Server, ServerInventory + +logger = logging.getLogger(__name__) +router = APIRouter(prefix="/api/inventory", tags=["서버 인벤토리"]) + +INVENTORY_COMMANDS = { + "os_info": "cat /etc/os-release 2>/dev/null | head -5", + "kernel": "uname -r", + "hostname": "hostname -f 2>/dev/null || hostname", + "uptime": "uptime -p 2>/dev/null || uptime", + "cpu_model": "grep 'model name' /proc/cpuinfo 2>/dev/null | head -1 | cut -d: -f2 | xargs", + "cpu_cores": "nproc", + "memory_total": "free -m | awk '/^Mem:/{print $2}'", + "disk_total": "df -BG --total 2>/dev/null | tail -1 | awk '{print $2}'", + "disk_used": "df -BG --total 2>/dev/null | tail -1 | awk '{print $3}'", + "open_ports": "ss -tlnp 2>/dev/null | awk 'NR>1{print $4}' | grep -oE '[0-9]+$' | sort -n | uniq | tr '\n' ','", + "running_svcs": "systemctl list-units --type=service --state=running 2>/dev/null | grep '.service' | awk '{print $1}' | tr '\n' ','", + "java_ver": "java -version 2>&1 | head -1", + "python_ver": "python3 --version 2>/dev/null", + "node_ver": "node --version 2>/dev/null || echo none", + "nginx_ver": "nginx -v 2>&1 || echo none", + "tomcat_ver": "find /opt /usr /home -name 'catalina.sh' 2>/dev/null | head -1 | xargs -I{} sh -c 'sh {} version 2>&1 | grep -i version | head -1' || echo none", + "mysql_ver": "mysql --version 2>/dev/null || echo none", + "postgres_ver": "psql --version 2>/dev/null || echo none", + "installed_pkgs":"dpkg -l 2>/dev/null | grep '^ii' | awk '{print $2\"=\"$3}' | head -50 || rpm -qa --queryformat '%{NAME}=%{VERSION}\n' 2>/dev/null | head -50", + "crontabs": "crontab -l 2>/dev/null | grep -v '^#' | grep -v '^$' | head -10", + "users": "getent passwd 2>/dev/null | awk -F: '$3>=1000 && $3<65534{print $1}' | tr '\n' ','", + "sudoers": "grep -r ALL /etc/sudoers /etc/sudoers.d/ 2>/dev/null | grep -v '^#' | head -5", + "last_updates": "tail -5 /var/log/dpkg.log 2>/dev/null || tail -5 /var/log/yum.log 2>/dev/null || echo none", +} + + +async def _collect_inventory(server: Server) -> dict: + try: + pw = decrypt_password(server.os_pw_enc) + ssh = paramiko.SSHClient() + ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy()) + ssh.connect(server.ip_addr, username=server.ssh_user, password=pw, timeout=10) + results = {} + for key, cmd in INVENTORY_COMMANDS.items(): + try: + _, stdout, _ = ssh.exec_command(cmd, timeout=8) + results[key] = stdout.read().decode('utf-8', 'replace').strip()[:500] + except Exception: + results[key] = "" + ssh.close() + return results + except Exception as e: + logger.warning(f"인벤토리 수집 실패 ({server.ip_addr}): {e}") + return {} + + +async def _do_collect(server_id: int, db: AsyncSession): + srv_row = await db.execute(select(Server).where(Server.id == server_id)) + server = srv_row.scalar_one_or_none() + if not server: + return + + data = await _collect_inventory(server) + if not data: + return + + inv = ServerInventory( + server_id=server_id, + data=data, + collected_at=datetime.utcnow(), + ) + db.add(inv) + # 서버 정보 업데이트 + if data.get("cpu_cores"): + try: server.cpu_cores = int(data["cpu_cores"]) + except ValueError: pass + if data.get("memory_total"): + try: server.memory_mb = int(data["memory_total"]) + except ValueError: pass + await db.commit() + + +@router.post("/collect/{server_id}") +async def collect_server_inventory( + server_id: int, + background_tasks: BackgroundTasks, + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + background_tasks.add_task(_do_collect, server_id, db) + return {"ok": True, "server_id": server_id, "queued": True} + + +@router.post("/collect-all") +async def collect_all_inventory( + background_tasks: BackgroundTasks, + db: AsyncSession = Depends(get_db), + user: User = Depends(require_admin_role), +): + rows = await db.execute(select(Server).limit(200)) + servers = rows.scalars().all() + for s in servers: + background_tasks.add_task(_do_collect, s.id, db) + return {"ok": True, "queued": len(servers)} + + +@router.get("/{server_id}") +async def get_inventory( + server_id: int, + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + row = await db.execute( + select(ServerInventory).where(ServerInventory.server_id == server_id) + .order_by(desc(ServerInventory.collected_at)).limit(1) + ) + inv = row.scalar_one_or_none() + if not inv: + raise HTTPException(404, "인벤토리 없음 — 먼저 수집하세요") + return {"server_id": server_id, "data": inv.data, "collected_at": inv.collected_at} + + +@router.get("/diff/{server_id}") +async def get_inventory_diff( + server_id: int, + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + """최근 2개 인벤토리 비교 — 변경사항 반환.""" + rows = await db.execute( + select(ServerInventory).where(ServerInventory.server_id == server_id) + .order_by(desc(ServerInventory.collected_at)).limit(2) + ) + invs = rows.scalars().all() + if len(invs) < 2: + return {"server_id": server_id, "diff": {}, "message": "비교할 이전 데이터 없음"} + + current = invs[0].data or {} + previous = invs[1].data or {} + diff = {} + for key in set(list(current.keys()) + list(previous.keys())): + c_val = current.get(key, "") + p_val = previous.get(key, "") + if c_val != p_val: + diff[key] = {"before": p_val[:200], "after": c_val[:200]} + + return { + "server_id": server_id, + "current_at": invs[0].collected_at, + "previous_at": invs[1].collected_at, + "changed_items": len(diff), + "diff": diff, + } + + +@router.get("/software") +async def software_inventory( + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + """전체 서버 소프트웨어 현황 — Java/Python/Nginx 버전 집계.""" + rows = await db.execute( + select(ServerInventory, Server.hostname).join( + Server, ServerInventory.server_id == Server.id + ).order_by(desc(ServerInventory.collected_at)) + ) + results = [] + seen_servers = set() + for row in rows.all(): + if row.ServerInventory.server_id in seen_servers: + continue + seen_servers.add(row.ServerInventory.server_id) + data = row.ServerInventory.data or {} + results.append({ + "server": row.hostname, + "java": data.get("java_ver", "none"), + "python": data.get("python_ver", "none"), + "nginx": data.get("nginx_ver", "none"), + "node": data.get("node_ver", "none"), + "mysql": data.get("mysql_ver", "none"), + "postgres": data.get("postgres_ver", "none"), + "collected_at": row.ServerInventory.collected_at, + }) + return results diff --git a/routers/cost_optimizer.py b/routers/cost_optimizer.py new file mode 100644 index 0000000..561d588 --- /dev/null +++ b/routers/cost_optimizer.py @@ -0,0 +1,94 @@ +""" +클라우드 비용 최적화 AI + +Ollama 기반 비용 분석·최적화 권고·Reserved Instance 추정. + +엔드포인트: + GET /api/costopt/analyze — 비용 패턴 분석 + POST /api/costopt/recommend — AI 최적화 권고 생성 + GET /api/costopt/idle-resources — 유휴 리소스 탐지 + GET /api/costopt/savings — 절감 가능 금액 추정 +""" +from __future__ import annotations + +import logging +from datetime import datetime + +import httpx +from fastapi import APIRouter, Depends +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 User, MultiCloudConfig + +logger = logging.getLogger(__name__) +router = APIRouter(prefix="/api/costopt", tags=["비용 최적화"]) +OLLAMA_URL = "http://localhost:11434" + + +async def _llm_analyze(prompt: str) -> str: + try: + async with httpx.AsyncClient(timeout=30) as c: + r = await c.post(f"{OLLAMA_URL}/api/generate", json={ + "model": "llama3", "prompt": prompt, "stream": False, + }) + return r.json().get("response", "").strip() if r.status_code == 200 else "" + except Exception: return "" + + +@router.get("/analyze") +async def analyze_costs(db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)): + rows = await db.execute(select(MultiCloudConfig).where(MultiCloudConfig.tenant_id == user.tenant_id, MultiCloudConfig.is_active == True)) + providers = rows.scalars().all() + return { + "provider_count": len(providers), + "providers": [{"name": p.name, "type": p.provider_type} for p in providers], + "analysis_note": "실제 비용 데이터는 각 CSP API 연동 후 제공", + "estimated_monthly": "설정 후 조회 가능", + } + + +@router.post("/recommend") +async def get_recommendations( + monthly_cost: float = 0, + user: User = Depends(get_current_user), +): + prompt = ( + f"공공기관 클라우드 월 비용: {monthly_cost:,.0f}원\n\n" + "다음 항목을 분석하여 절감 방안을 JSON 형식으로 제시:\n" + "1. Reserved Instance 전환 가능 여부 (On-demand → RI 최대 60% 절감)\n" + "2. 개발/테스트 환경 야간/주말 자동 중지 (최대 65% 절감)\n" + "3. 스토리지 최적화 (미연결 볼륨, 오래된 스냅샷)\n" + "4. 인스턴스 다운사이징 (사용률 10% 미만)\n" + 'JSON 형식: {"recommendations": [...], "estimated_savings": 금액, "priority": "HIGH|MEDIUM|LOW"}' + ) + result = await _llm_analyze(prompt) + import json, re + match = re.search(r'\{.*\}', result, re.DOTALL) + try: + data = json.loads(match.group()) if match else {} + except Exception: + data = {"raw": result} + return {"monthly_cost": monthly_cost, "recommendations": data} + + +@router.get("/idle-resources") +async def detect_idle_resources(user: User = Depends(get_current_user)): + return { + "idle_resources": [], + "note": "CSP API 연동 후 CPU 사용률 < 10% 인스턴스 자동 탐지", + "potential_savings_pct": 20, + } + + +@router.get("/savings") +async def estimate_savings(user: User = Depends(get_current_user)): + return { + "ri_conversion": {"savings_pct": 40, "note": "On-demand → 1년 RI 전환 시"}, + "schedule_optimization": {"savings_pct": 30, "note": "개발환경 야간/주말 중지 시"}, + "rightsizing": {"savings_pct": 15, "note": "유휴 인스턴스 다운사이징 시"}, + "total_estimated_savings_pct": 55, + "last_updated": datetime.utcnow(), + } diff --git a/routers/dependency_map.py b/routers/dependency_map.py new file mode 100644 index 0000000..51c1185 --- /dev/null +++ b/routers/dependency_map.py @@ -0,0 +1,231 @@ +""" +서비스 의존성 자동 매핑 + +SSH 경유 netstat/ss 분석으로 서비스 간 upstream/downstream 의존성을 자동 탐지. + +엔드포인트: + POST /api/depmap/discover/{server_id} — 단일 서버 의존성 탐지 + POST /api/depmap/discover-all — 전체 서버 의존성 탐지 + GET /api/depmap/ — 의존성 맵 조회 + GET /api/depmap/impact/{ci_id} — 특정 CI 영향 범위 분석 + DELETE /api/depmap/{dep_id} — 의존성 수동 삭제 +""" +from __future__ import annotations + +import logging +import re +from datetime import datetime + +import paramiko +from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException +from sqlalchemy import select, or_ +from sqlalchemy.ext.asyncio import AsyncSession + +from core.auth import get_current_user, require_admin_role +from core.ssh_exec import _decrypt_password as decrypt_password +from database import get_db +from models import User, Server, ServiceDependency + +logger = logging.getLogger(__name__) +router = APIRouter(prefix="/api/depmap", tags=["서비스 의존성 맵"]) + + +async def _ssh_run(server: Server, cmd: str) -> str: + try: + pw = decrypt_password(server.os_pw_enc) + ssh = paramiko.SSHClient() + ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy()) + ssh.connect(server.ip_addr, username=server.ssh_user, password=pw, timeout=10) + _, stdout, _ = ssh.exec_command(cmd, timeout=15) + result = stdout.read().decode('utf-8', 'replace').strip() + ssh.close() + return result + except Exception as e: + logger.warning(f"SSH 실패 ({server.ip_addr}): {e}") + return "" + + +async def _discover_connections(server: Server) -> list[dict]: + """netstat/ss로 ESTABLISHED 연결 분석 → 의존성 추출.""" + output = await _ssh_run( + server, + "ss -tnp state established 2>/dev/null | awk 'NR>1{print $4,$5}' || " + "netstat -tnp 2>/dev/null | grep ESTABLISHED | awk '{print $4,$5}'" + ) + connections = [] + seen = set() + for line in output.splitlines(): + parts = line.split() + if len(parts) < 2: + continue + local, remote = parts[0], parts[1] + # 외부 IP:포트 추출 + m = re.match(r'(\d+\.\d+\.\d+\.\d+):(\d+)$', remote) + if not m: + continue + remote_ip, remote_port = m.group(1), int(m.group(2)) + if remote_ip in ("127.0.0.1", "0.0.0.0"): + continue + key = f"{remote_ip}:{remote_port}" + if key in seen: + continue + seen.add(key) + # 포트 → 서비스 유형 추정 + service_map = { + 3306: "MySQL", 5432: "PostgreSQL", 6379: "Redis", + 27017: "MongoDB", 9200: "Elasticsearch", + 80: "HTTP", 443: "HTTPS", 8080: "HTTP-Alt", + 2181: "Zookeeper", 9092: "Kafka", 5672: "RabbitMQ", + } + dep_type = service_map.get(remote_port, f"TCP:{remote_port}") + connections.append({ + "remote_ip": remote_ip, "remote_port": remote_port, + "dependency_type": dep_type, "protocol": "TCP", + }) + return connections + + +async def _do_discover(server_id: int, db: AsyncSession): + """단일 서버 의존성 탐지 (백그라운드).""" + srv_row = await db.execute(select(Server).where(Server.id == server_id)) + server = srv_row.scalar_one_or_none() + if not server: + return + + connections = await _discover_connections(server) + new_deps = 0 + for conn in connections: + # 원격 IP가 CMDB에 있는지 확인 + remote_srv_row = await db.execute( + select(Server).where(Server.ip_addr == conn["remote_ip"]) + ) + remote_server = remote_srv_row.scalar_one_or_none() + if not remote_server: + continue # CMDB에 없는 서버는 스킵 + + # 중복 체크 + existing = await db.execute( + select(ServiceDependency).where( + ServiceDependency.upstream_ci_id == server_id, + ServiceDependency.downstream_ci_id == remote_server.id, + ServiceDependency.port == conn["remote_port"], + ) + ) + if existing.scalar_one_or_none(): + continue + + dep = ServiceDependency( + upstream_ci_id=server_id, + downstream_ci_id=remote_server.id, + dependency_type=conn["dependency_type"], + port=conn["remote_port"], + protocol=conn["protocol"], + discovered_at=datetime.utcnow(), + ) + db.add(dep) + new_deps += 1 + + await db.commit() + logger.info(f"서버 {server_id} 의존성 {new_deps}개 발견") + + +@router.post("/discover/{server_id}") +async def discover_server_deps( + server_id: int, + background_tasks: BackgroundTasks, + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + background_tasks.add_task(_do_discover, server_id, db) + return {"ok": True, "server_id": server_id, "queued": True} + + +@router.post("/discover-all") +async def discover_all_deps( + background_tasks: BackgroundTasks, + db: AsyncSession = Depends(get_db), + user: User = Depends(require_admin_role), +): + rows = await db.execute(select(Server).limit(100)) + servers = rows.scalars().all() + for s in servers: + background_tasks.add_task(_do_discover, s.id, db) + return {"ok": True, "queued": len(servers)} + + +@router.get("/") +async def get_dependency_map( + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + rows = await db.execute( + select( + ServiceDependency, + Server.hostname.label("up_name"), Server.ip_addr.label("up_ip"), + ).join(Server, ServiceDependency.upstream_ci_id == Server.id) + .limit(500) + ) + deps = rows.all() + return [ + { + "id": d.ServiceDependency.id, + "upstream": {"id": d.ServiceDependency.upstream_ci_id, "name": d.up_name, "ip": d.up_ip}, + "downstream_id": d.ServiceDependency.downstream_ci_id, + "type": d.ServiceDependency.dependency_type, + "port": d.ServiceDependency.port, + "discovered_at": d.ServiceDependency.discovered_at, + } + for d in deps + ] + + +@router.get("/impact/{ci_id}") +async def get_impact_analysis( + ci_id: int, + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + """특정 CI 장애 시 영향 범위 분석 (upstream 서비스 = 영향받는 서비스).""" + # ci_id에 의존하는 서비스들 (ci_id가 downstream인 경우) + rows = await db.execute( + select(ServiceDependency, Server.hostname, Server.ip_addr).join( + Server, ServiceDependency.upstream_ci_id == Server.id + ).where(ServiceDependency.downstream_ci_id == ci_id) + ) + impacted = rows.all() + + # ci_id가 의존하는 서비스들 (ci_id가 upstream인 경우) + dep_rows = await db.execute( + select(ServiceDependency, Server.hostname, Server.ip_addr).join( + Server, ServiceDependency.downstream_ci_id == Server.id + ).where(ServiceDependency.upstream_ci_id == ci_id) + ) + dependencies = dep_rows.all() + + return { + "ci_id": ci_id, + "impacted_services": [ + {"server": r.hostname, "ip": r.ip_addr, "depends_via": r.ServiceDependency.dependency_type} + for r in impacted + ], + "depends_on": [ + {"server": r.hostname, "ip": r.ip_addr, "type": r.ServiceDependency.dependency_type} + for r in dependencies + ], + "blast_radius": len(impacted), + } + + +@router.delete("/{dep_id}") +async def delete_dependency( + dep_id: int, + db: AsyncSession = Depends(get_db), + user: User = Depends(require_admin_role), +): + row = await db.execute(select(ServiceDependency).where(ServiceDependency.id == dep_id)) + dep = row.scalar_one_or_none() + if not dep: + raise HTTPException(404) + await db.delete(dep) + await db.commit() + return {"ok": True} diff --git a/routers/doc_template.py b/routers/doc_template.py new file mode 100644 index 0000000..2240f63 --- /dev/null +++ b/routers/doc_template.py @@ -0,0 +1,341 @@ +""" +문서 추출 템플릿 관리 + +내장 7종 + 커스텀 템플릿 CRUD. + +엔드포인트: + GET /api/doctemplate/ — 템플릿 목록 + POST /api/doctemplate/ — 커스텀 템플릿 생성 + GET /api/doctemplate/{id} — 템플릿 상세 + PUT /api/doctemplate/{id} — 수정 + DELETE /api/doctemplate/{id} — 삭제 + GET /api/doctemplate/builtin — 내장 템플릿 목록 + POST /api/doctemplate/apply-builtin — 내장 템플릿 테넌트 적용 +""" +from __future__ import annotations + +import json +from datetime import datetime +from typing import Optional + +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel, Field +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from core.auth import get_current_user, require_admin_role +from database import get_db +from models import User, DocTemplate + +router = APIRouter(prefix="/api/doctemplate", tags=["문서 템플릿"]) + +BUILTIN_TEMPLATES = { + "narasajang_contract": { + "name": "나라장터 계약서", + "description": "조달청 나라장터 계약서에서 계약정보를 자동 추출", + "workflow": "contract", + "schema": { + "contract_no": "계약번호", + "contract_name": "계약품명/서비스명", + "supplier": "공급사명", + "supplier_biz_no":"공급사 사업자번호", + "amount": "계약금액(원)", + "vat": "부가세액", + "start_date": "계약시작일(YYYY-MM-DD)", + "end_date": "계약종료일(YYYY-MM-DD)", + "institution": "발주기관명", + "manager": "담당자명", + "payment_terms": "납부/지급 조건", + } + }, + "server_delivery": { + "name": "서버 납품 명세서", + "description": "서버·장비 납품명세서에서 사양을 추출하여 CMDB에 자동 등록", + "workflow": "server_spec", + "schema": { + "hostname": "호스트명/서버명", + "manufacturer": "제조사", + "model_no": "모델번호", + "serial_no": "시리얼번호", + "cpu_model": "CPU 모델명", + "cpu_cores": "CPU 코어 수", + "memory_gb": "메모리 용량(GB)", + "disk_config": "스토리지 구성(예: SSD 1TB×2)", + "os": "운영체제", + "ip_addr": "IP주소", + "rack_location": "랙/위치", + "warranty_until": "보증기간 만료일", + "delivery_date": "납품일", + } + }, + "brand_contract": { + "name": "기업 브랜드 계약서", + "description": "현대백화점·롯데 등 유통/브랜드 계약서 자동 처리", + "workflow": "brand_contract", + "schema": { + "contract_title": "계약서 제목", + "party_a": "갑(발주사/브랜드사)", + "party_a_biz_no": "갑 사업자번호", + "party_b": "을(수주사/입점사)", + "party_b_biz_no": "을 사업자번호", + "contract_amount": "계약금액", + "currency": "통화(KRW/USD/기타)", + "effective_date": "계약체결일", + "expiry_date": "계약만료일", + "auto_renewal": "자동갱신여부(Y/N)", + "payment_terms": "대금 지급조건", + "contract_items": "계약 품목/서비스", + "royalty_rate": "수수료율/로열티율", + "territory": "적용지역/매장", + "exclusive": "독점여부(Y/N)", + "termination": "계약 해지 조건", + "penalty_clause": "위약금 조항", + "contact_a": "갑 담당자", + "contact_b": "을 담당자", + "special_terms": "특약사항", + } + }, + "invoice": { + "name": "세금계산서/청구서", + "description": "세금계산서·청구서에서 금액·공급자 정보 자동 추출", + "workflow": "invoice", + "schema": { + "invoice_no": "세금계산서번호/청구번호", + "issue_date": "발행일", + "supplier_name": "공급자 상호", + "supplier_biz_no": "공급자 사업자번호", + "buyer_name": "공급받는자 상호", + "buyer_biz_no": "공급받는자 사업자번호", + "supply_amount": "공급가액", + "vat_amount": "세액", + "total_amount": "합계금액", + "items": "품목/내역(쉼표 구분)", + "payment_due": "결제기한", + } + }, + "incident_report": { + "name": "장애 보고서", + "description": "장애보고서 이미지/PDF에서 에러 내용 추출 → SR 자동 생성", + "workflow": "incident_report", + "schema": { + "incident_date": "발생일시", + "incident_type": "장애유형(H/W·S/W·네트워크·기타)", + "affected_system": "영향 시스템/서비스", + "error_message": "오류 메시지/에러코드", + "root_cause": "근본원인", + "impact_scope": "영향 범위(사용자 수/서비스)", + "resolution": "조치사항", + "downtime_minutes": "다운타임(분)", + "reporter": "보고자/담당자", + "severity": "심각도(P1/P2/P3/P4)", + } + }, + "csap_report": { + "name": "CSAP/ISMS 점검 보고서", + "description": "공공기관 보안 점검 보고서 자동 분석 → CSAP 준수율 업데이트", + "workflow": "audit_report", + "schema": { + "institution": "기관명", + "check_date": "점검일", + "auditor": "점검자/감사기관", + "total_items": "총 점검항목 수", + "passed_items": "적합(통과) 항목 수", + "failed_items": "부적합 항목 수", + "na_items": "해당없음 항목 수", + "compliance_rate": "준수율(%)", + "major_findings": "주요 발견사항", + "recommendations": "권고사항", + "next_check_date": "차기 점검 예정일", + } + }, + "meeting_minutes": { + "name": "회의록", + "description": "회의록에서 결정사항·액션아이템 자동 추출 → SR/작업 생성", + "workflow": "meeting_minutes", + "schema": { + "meeting_date": "회의일시", + "meeting_place": "장소(오프라인/화상)", + "chairman": "의장/주관자", + "participants": "참석자 목록", + "agenda": "회의 안건", + "decisions": "결정사항(쉼표 구분)", + "action_items": "액션아이템(담당자/기한 포함)", + "next_meeting": "차기 회의 일정", + "notes": "기타 특이사항", + } + }, +} + + +class TemplateCreate(BaseModel): + name: str = Field(..., max_length=200) + description: Optional[str] = None + schema_json: dict = Field(..., description="추출 스키마 {필드명: 설명}") + workflow: Optional[str] = Field(None, description="연동 워크플로우") + + +class ApplyBuiltinRequest(BaseModel): + template_keys: list[str] + + +@router.get("/builtin") +async def list_builtin_templates(_: User = Depends(get_current_user)): + return [ + { + "key": k, + "name": v["name"], + "description": v["description"], + "workflow": v["workflow"], + "field_count": len(v["schema"]), + "fields": list(v["schema"].keys()), + } + for k, v in BUILTIN_TEMPLATES.items() + ] + + +@router.post("/apply-builtin") +async def apply_builtin_templates( + req: ApplyBuiltinRequest, + db: AsyncSession = Depends(get_db), + user: User = Depends(require_admin_role), +): + """내장 템플릿을 현재 테넌트에 적용.""" + created = [] + for key in req.template_keys: + tpl = BUILTIN_TEMPLATES.get(key) + if not tpl: + continue + existing = await db.execute( + select(DocTemplate).where( + DocTemplate.tenant_id == user.tenant_id, + DocTemplate.builtin_key == key, + ) + ) + if existing.scalar_one_or_none(): + continue + tmpl = DocTemplate( + tenant_id=user.tenant_id, + name=tpl["name"], + description=tpl["description"], + schema_json=json.dumps(tpl["schema"], ensure_ascii=False), + workflow=tpl["workflow"], + builtin_key=key, + is_builtin=True, + is_active=True, + created_at=datetime.utcnow(), + ) + db.add(tmpl) + created.append(tpl["name"]) + await db.commit() + return {"ok": True, "created": created, "count": len(created)} + + +@router.get("/") +async def list_templates( + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + rows = await db.execute( + select(DocTemplate).where( + DocTemplate.tenant_id == user.tenant_id, + DocTemplate.is_active == True, + ).order_by(DocTemplate.is_builtin.desc(), DocTemplate.name) + ) + templates = rows.scalars().all() + return [ + { + "id": t.id, "name": t.name, "description": t.description, + "workflow": t.workflow, "is_builtin": t.is_builtin, + "field_count": len(json.loads(t.schema_json or "{}")), + "created_at": t.created_at, + } + for t in templates + ] + + +@router.post("/") +async def create_template( + req: TemplateCreate, + db: AsyncSession = Depends(get_db), + user: User = Depends(require_admin_role), +): + tmpl = DocTemplate( + tenant_id=user.tenant_id, + name=req.name, description=req.description, + schema_json=json.dumps(req.schema_json, ensure_ascii=False), + workflow=req.workflow, is_builtin=False, is_active=True, + created_at=datetime.utcnow(), + ) + db.add(tmpl) + await db.commit() + await db.refresh(tmpl) + return {"ok": True, "id": tmpl.id} + + +@router.get("/{template_id}") +async def get_template( + template_id: int, + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + row = await db.execute( + select(DocTemplate).where( + DocTemplate.id == template_id, + DocTemplate.tenant_id == user.tenant_id, + ) + ) + t = row.scalar_one_or_none() + if not t: + raise HTTPException(404) + return { + "id": t.id, "name": t.name, "description": t.description, + "schema": json.loads(t.schema_json or "{}"), + "workflow": t.workflow, "is_builtin": t.is_builtin, + } + + +@router.put("/{template_id}") +async def update_template( + template_id: int, + req: TemplateCreate, + db: AsyncSession = Depends(get_db), + user: User = Depends(require_admin_role), +): + row = await db.execute( + select(DocTemplate).where( + DocTemplate.id == template_id, + DocTemplate.tenant_id == user.tenant_id, + ) + ) + t = row.scalar_one_or_none() + if not t: + raise HTTPException(404) + if t.is_builtin: + raise HTTPException(400, "내장 템플릿은 수정할 수 없습니다. 복제 후 수정하세요.") + t.name = req.name; t.description = req.description + t.schema_json = json.dumps(req.schema_json, ensure_ascii=False) + t.workflow = req.workflow + await db.commit() + return {"ok": True} + + +@router.delete("/{template_id}") +async def delete_template( + template_id: int, + db: AsyncSession = Depends(get_db), + user: User = Depends(require_admin_role), +): + row = await db.execute( + select(DocTemplate).where( + DocTemplate.id == template_id, + DocTemplate.tenant_id == user.tenant_id, + ) + ) + t = row.scalar_one_or_none() + if not t: + raise HTTPException(404) + if t.is_builtin: + raise HTTPException(400, "내장 템플릿은 삭제할 수 없습니다.") + t.is_active = False + await db.commit() + return {"ok": True} diff --git a/routers/doc_workflow.py b/routers/doc_workflow.py new file mode 100644 index 0000000..576ec9b --- /dev/null +++ b/routers/doc_workflow.py @@ -0,0 +1,610 @@ +""" +문서 워크플로우 자동화 — OCR 결과 → ITSM 자동 연동 + +Upstage OCR 결과를 ITSM 기능에 자동 연동하는 7개 워크플로우. + +엔드포인트: + POST /api/docflow/contract — 나라장터 계약서 → 조달 자동 등록 + POST /api/docflow/server-spec — 서버납품서 → CMDB 자동 등록 + POST /api/docflow/invoice — 청구서/세금계산서 → 과금 연동 + POST /api/docflow/audit-report — CSAP/감사보고서 → 준수율 업데이트 + POST /api/docflow/incident-report — 장애보고서 이미지 → SR 자동 생성 + POST /api/docflow/meeting-minutes — 회의록 → SR/액션아이템 생성 + POST /api/docflow/brand-contract — 기업 브랜드 계약서 (현대백화점 등) + GET /api/docflow/jobs — 작업 목록 + GET /api/docflow/jobs/{id} — 작업 상세 +""" +from __future__ import annotations + +import json +import logging +import re +from datetime import date, datetime +from typing import Optional + +import httpx +from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile +from pydantic import BaseModel +from sqlalchemy import select, desc +from sqlalchemy.ext.asyncio import AsyncSession + +from core.auth import get_current_user +from database import get_db +from models import ( + User, UpstageOCRConfig, OCRHistory, DocWorkflowJob, + SRRequest, SRStatus, Server, ProcurementRecord, Invoice, +) + +logger = logging.getLogger(__name__) +router = APIRouter(prefix="/api/docflow", tags=["문서 워크플로우"]) + +UPSTAGE_BASE = "https://api.upstage.ai/v1/document-ai" +MAX_FILE_SIZE = 20 * 1024 * 1024 + + +# ── 내부 헬퍼 ──────────────────────────────────────────────────────────────── + +def _parse_amount(text: str) -> int: + """금액 문자열 → 정수 (₩50,000,000 → 50000000).""" + if not text: + return 0 + cleaned = re.sub(r'[^\d]', '', str(text)) + return int(cleaned) if cleaned else 0 + + +def _parse_date(text: str) -> Optional[date]: + """날짜 문자열 → date (다양한 형식 지원).""" + if not text: + return None + formats = ["%Y-%m-%d", "%Y.%m.%d", "%Y/%m/%d", "%Y년 %m월 %d일", "%Y%m%d"] + cleaned = str(text).strip() + for fmt in formats: + try: + return datetime.strptime(cleaned, fmt).date() + except ValueError: + continue + return None + + +async def _get_api_key(user: User, db: AsyncSession) -> str: + row = await db.execute( + select(UpstageOCRConfig).where( + UpstageOCRConfig.tenant_id == user.tenant_id, + UpstageOCRConfig.is_active == True, + ) + ) + cfg = row.scalar_one_or_none() + if not cfg: + raise HTTPException(404, "Upstage API Key 미설정. POST /api/ocr/config 에서 설정하세요.") + return cfg.api_key_enc + + +async def _extract(api_key: str, file_bytes: bytes, filename: str, + schema: dict) -> dict: + """Upstage Information Extraction 호출.""" + from pathlib import Path + MIME = {".pdf": "application/pdf", ".png": "image/png", + ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".tiff": "image/tiff"} + ext = Path(filename).suffix.lower() + mime = MIME.get(ext, "application/octet-stream") + async with httpx.AsyncClient(timeout=120) as client: + r = await client.post( + f"{UPSTAGE_BASE}/information-extraction", + headers={"Authorization": f"Bearer {api_key}"}, + files={"document": (filename, file_bytes, mime)}, + data={"schema": json.dumps(schema, ensure_ascii=False)} + ) + if r.status_code != 200: + raise HTTPException(502, f"Upstage API 오류: {r.text[:200]}") + return r.json() + + +async def _parse_doc(api_key: str, file_bytes: bytes, filename: str) -> dict: + """Upstage Document Parse 호출.""" + from pathlib import Path + MIME = {".pdf": "application/pdf", ".png": "image/png", + ".jpg": "image/jpeg", ".jpeg": "image/jpeg"} + ext = Path(filename).suffix.lower() + mime = MIME.get(ext, "application/octet-stream") + async with httpx.AsyncClient(timeout=120) as client: + r = await client.post( + f"{UPSTAGE_BASE}/document-digitization", + headers={"Authorization": f"Bearer {api_key}"}, + files={"document": (filename, file_bytes, mime)}, + data={"model": "document-parse-ocr", "ocr": "auto", + "output_formats": '["text"]'} + ) + if r.status_code != 200: + raise HTTPException(502, f"Upstage API 오류: {r.text[:200]}") + return r.json() + + +async def _save_job(db: AsyncSession, tenant_id: int, user_id: int, + workflow: str, filename: str, template_id: Optional[int], + extracted: dict, linked_table: str, + linked_id: Optional[int], status: str = "DONE") -> int: + job = DocWorkflowJob( + tenant_id=tenant_id, + workflow_type=workflow, + filename=filename, + template_id=template_id, + status=status, + extracted_data=extracted, + linked_table=linked_table, + linked_record_id=linked_id, + created_by=user_id, + created_at=datetime.utcnow(), + completed_at=datetime.utcnow(), + ) + db.add(job) + await db.commit() + await db.refresh(job) + return job.id + + +def _simplify(result: dict) -> dict: + """Upstage 추출 결과 → 단순 Key-Value.""" + if "result" in result and isinstance(result["result"], dict): + return {k: v.get("value", "") if isinstance(v, dict) else v + for k, v in result["result"].items()} + return {} + + +# ── 워크플로우 엔드포인트 ─────────────────────────────────────────────────── + +@router.post("/contract") +async def process_contract( + file: UploadFile = File(...), + auto_register: bool = Form(True), + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + """나라장터 계약서 → 조달 이력 자동 등록.""" + file_bytes = await file.read() + api_key = await _get_api_key(user, db) + + schema = { + "contract_no": "계약번호", "contract_name": "계약품명", + "supplier": "공급사명", "supplier_biz_no": "공급사 사업자번호", + "amount": "계약금액(원)", "vat": "부가세액", + "start_date": "계약시작일", "end_date": "계약종료일", + "institution": "발주기관명", "manager": "담당자명", + "payment_terms": "납부조건", + } + result = await _extract(api_key, file_bytes, file.filename or "contract.pdf", schema) + extracted = _simplify(result) + + record_id = None + if auto_register and extracted.get("contract_no"): + record = ProcurementRecord( + tenant_id=user.tenant_id, + contract_no=extracted.get("contract_no", ""), + contract_name=extracted.get("contract_name", "미상"), + supplier=extracted.get("supplier", ""), + amount=_parse_amount(extracted.get("amount", "0")), + category="IT계약", + start_date=_parse_date(extracted.get("start_date")), + end_date=_parse_date(extracted.get("end_date")), + status="ACTIVE", + created_at=datetime.utcnow(), + ) + db.add(record) + await db.commit() + await db.refresh(record) + record_id = record.id + + job_id = await _save_job(db, user.tenant_id, user.id, "contract", + file.filename or "", None, extracted, + "tb_procurement_record", record_id) + + return { + "ok": True, + "workflow": "contract", + "extracted": extracted, + "record_id": record_id, + "job_id": job_id, + "message": f"계약 정보 추출 완료" + (f" → 조달 ID {record_id} 등록" if record_id else " (수동 확인 필요)"), + } + + +@router.post("/server-spec") +async def process_server_spec( + file: UploadFile = File(...), + auto_register: bool = Form(True), + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + """서버 납품 명세서 → CMDB 자동 등록.""" + file_bytes = await file.read() + api_key = await _get_api_key(user, db) + + schema = { + "hostname": "호스트명/서버명", "manufacturer": "제조사", + "model_no": "모델번호", "serial_no": "시리얼번호", + "cpu_model": "CPU 모델명", "cpu_cores": "CPU 코어 수", + "memory_gb": "메모리 용량(GB)", "disk_config": "스토리지 구성", + "os": "운영체제", "ip_addr": "IP주소", + "rack_location": "랙/위치", "warranty_until": "보증기간 만료일", + "delivery_date": "납품일", + } + result = await _extract(api_key, file_bytes, file.filename or "spec.pdf", schema) + extracted = _simplify(result) + + server_id = None + if auto_register and extracted.get("hostname"): + server = Server( + hostname=extracted.get("hostname", ""), + ip_addr=extracted.get("ip_addr", "0.0.0.0"), + os_type=extracted.get("os", ""), + cpu_cores=int(re.sub(r'[^\d]', '', extracted.get("cpu_cores", "0") or "0") or 0), + memory_mb=int(re.sub(r'[^\d]', '', extracted.get("memory_gb", "0") or "0") or 0) * 1024, + ssh_user="opsagent", + discovered_at=datetime.utcnow(), + ) + db.add(server) + await db.commit() + await db.refresh(server) + server_id = server.id + + job_id = await _save_job(db, user.tenant_id, user.id, "server_spec", + file.filename or "", None, extracted, + "tb_server_info", server_id) + + return { + "ok": True, + "workflow": "server_spec", + "extracted": extracted, + "server_id": server_id, + "job_id": job_id, + "message": f"서버 사양 추출 완료" + (f" → CMDB ID {server_id} 등록" if server_id else " (수동 확인 필요)"), + } + + +@router.post("/invoice") +async def process_invoice( + file: UploadFile = File(...), + auto_register: bool = Form(True), + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + """청구서/세금계산서 → 과금 Invoice 자동 등록.""" + file_bytes = await file.read() + api_key = await _get_api_key(user, db) + + schema = { + "invoice_no": "세금계산서번호/청구번호", + "issue_date": "발행일", + "supplier_name": "공급자 상호", + "supplier_biz_no": "공급자 사업자번호", + "buyer_name": "공급받는자 상호", + "supply_amount": "공급가액", + "vat_amount": "세액", + "total_amount": "합계금액", + "items": "품목/내역", + "payment_due": "결제기한", + } + result = await _extract(api_key, file_bytes, file.filename or "invoice.pdf", schema) + extracted = _simplify(result) + + invoice_id = None + if auto_register and extracted.get("total_amount"): + today = date.today() + invoice = Invoice( + tenant_id=user.tenant_id, + plan="OCR_IMPORT", + period=today.strftime("%Y-%m"), + amount=_parse_amount(extracted.get("total_amount", "0")), + status="DRAFT", + generated_by=user.id, + created_at=datetime.utcnow(), + ) + db.add(invoice) + await db.commit() + await db.refresh(invoice) + invoice_id = invoice.id + + job_id = await _save_job(db, user.tenant_id, user.id, "invoice", + file.filename or "", None, extracted, + "tb_invoice", invoice_id) + + return { + "ok": True, + "workflow": "invoice", + "extracted": extracted, + "invoice_id": invoice_id, + "job_id": job_id, + "total_amount": _parse_amount(extracted.get("total_amount", "0")), + } + + +@router.post("/audit-report") +async def process_audit_report( + file: UploadFile = File(...), + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + """CSAP/감사 보고서 → 준수율 정보 추출.""" + file_bytes = await file.read() + api_key = await _get_api_key(user, db) + + schema = { + "institution": "기관명", "check_date": "점검일", + "auditor": "점검자/감사기관", + "total_items": "총 점검항목 수", + "passed_items": "적합(통과) 항목 수", + "failed_items": "부적합 항목 수", + "compliance_rate": "준수율(%)", + "major_findings": "주요 발견사항", + "recommendations": "권고사항", + } + result = await _extract(api_key, file_bytes, file.filename or "audit.pdf", schema) + extracted = _simplify(result) + + job_id = await _save_job(db, user.tenant_id, user.id, "audit_report", + file.filename or "", None, extracted, "audit", None) + + compliance_rate = float(re.sub(r'[^\d.]', '', extracted.get("compliance_rate", "0") or "0") or 0) + return { + "ok": True, + "workflow": "audit_report", + "extracted": extracted, + "compliance_rate": compliance_rate, + "job_id": job_id, + "message": f"감사 보고서 분석 완료. 준수율: {compliance_rate}%", + } + + +@router.post("/incident-report") +async def process_incident_report( + file: UploadFile = File(...), + auto_create_sr: bool = Form(True), + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + """장애보고서 이미지/PDF → 에러 내용 추출 → SR 자동 생성.""" + file_bytes = await file.read() + api_key = await _get_api_key(user, db) + + # Document Parse로 텍스트 추출 + parse_result = await _parse_doc(api_key, file_bytes, file.filename or "incident.png") + text = parse_result.get("content", {}).get("text", "") if isinstance(parse_result.get("content"), dict) else "" + + # 추가로 정보 추출 + schema = { + "incident_date": "발생일시", + "incident_type": "장애유형", + "affected_system": "영향 시스템", + "error_message": "오류 메시지", + "severity": "심각도(P1/P2/P3/P4)", + "reporter": "보고자", + } + extract_result = await _extract(api_key, file_bytes, file.filename or "incident.png", schema) + extracted = _simplify(extract_result) + + sr_id = None + if auto_create_sr: + severity = extracted.get("severity", "P3") + priority = {"P1": "HIGH", "P2": "HIGH", "P3": "MEDIUM", "P4": "LOW"}.get(severity.upper(), "MEDIUM") + title = f"[장애보고서] {extracted.get('incident_type', '장애')} - {extracted.get('affected_system', '미상')}" + description = ( + f"OCR 추출 장애보고서\n\n" + f"발생일시: {extracted.get('incident_date', '-')}\n" + f"장애유형: {extracted.get('incident_type', '-')}\n" + f"영향 시스템: {extracted.get('affected_system', '-')}\n" + f"오류 메시지: {extracted.get('error_message', '-')}\n\n" + f"원본 텍스트:\n{text[:500]}" + ) + sr = SRRequest( + title=title[:100], + description=description, + category="INCIDENT", + priority=priority, + status=SRStatus.OPEN, + created_at=datetime.utcnow(), + ) + db.add(sr) + await db.commit() + await db.refresh(sr) + sr_id = sr.id + + job_id = await _save_job(db, user.tenant_id, user.id, "incident_report", + file.filename or "", None, extracted, "tb_sr_request", sr_id) + + return { + "ok": True, + "workflow": "incident_report", + "extracted": extracted, + "sr_id": sr_id, + "job_id": job_id, + "message": f"장애 보고서 분석 완료" + (f" → SR-{sr_id} 생성" if sr_id else ""), + } + + +@router.post("/meeting-minutes") +async def process_meeting_minutes( + file: UploadFile = File(...), + auto_create_sr: bool = Form(True), + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + """회의록 → 결정사항/액션아이템 추출 → SR 자동 생성.""" + file_bytes = await file.read() + api_key = await _get_api_key(user, db) + + schema = { + "meeting_date": "회의일시", + "chairman": "의장/주관자", + "participants": "참석자 목록", + "agenda": "회의 안건", + "decisions": "결정사항", + "action_items": "액션아이템(담당자/기한 포함)", + "next_meeting": "차기 회의 일정", + } + result = await _extract(api_key, file_bytes, file.filename or "meeting.pdf", schema) + extracted = _simplify(result) + + sr_ids = [] + if auto_create_sr and extracted.get("action_items"): + # 액션아이템별로 SR 생성 + action_text = extracted.get("action_items", "") + items = [a.strip() for a in re.split(r'[,\n]', action_text) if a.strip()] + for item in items[:5]: # 최대 5개 SR + sr = SRRequest( + title=f"[회의록 액션] {item[:80]}", + description=f"회의일: {extracted.get('meeting_date', '-')}\n의장: {extracted.get('chairman', '-')}\n\n액션아이템: {item}", + category="TASK", + priority="MEDIUM", + status=SRStatus.OPEN, + created_at=datetime.utcnow(), + ) + db.add(sr) + await db.commit() + await db.refresh(sr) + sr_ids.append(sr.id) + + job_id = await _save_job(db, user.tenant_id, user.id, "meeting_minutes", + file.filename or "", None, extracted, "tb_sr_request", + sr_ids[0] if sr_ids else None) + + return { + "ok": True, + "workflow": "meeting_minutes", + "extracted": extracted, + "sr_ids": sr_ids, + "job_id": job_id, + "message": f"회의록 분석 완료" + (f" → SR {sr_ids} 생성" if sr_ids else ""), + } + + +@router.post("/brand-contract") +async def process_brand_contract( + file: UploadFile = File(...), + auto_register: bool = Form(True), + brand_name: str = Form("", description="브랜드사명 (예: 현대백화점)"), + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + """ + 기업 브랜드 계약서 처리 — 현대백화점·롯데·신세계 등 유통/브랜드 계약. + 나라장터 외 일반 B2B 계약서를 자동 파싱하여 계약 이력에 등록. + """ + file_bytes = await file.read() + api_key = await _get_api_key(user, db) + + # 브랜드 계약서 전용 스키마 + schema = { + "contract_title": "계약서 제목", + "party_a": "갑(발주사/브랜드사)", + "party_a_biz_no": "갑 사업자번호", + "party_b": "을(수주사/입점사/공급사)", + "party_b_biz_no": "을 사업자번호", + "contract_amount": "계약금액(숫자만)", + "currency": "통화(KRW/USD/기타)", + "effective_date": "계약체결일(YYYY-MM-DD)", + "expiry_date": "계약만료일(YYYY-MM-DD)", + "auto_renewal": "자동갱신여부(Y/N)", + "payment_terms": "대금 지급조건", + "contract_items": "계약 품목/서비스", + "royalty_rate": "수수료율/로열티율", + "territory": "적용지역/매장명", + "exclusive": "독점여부(Y/N)", + "termination": "계약 해지 조건", + "penalty_clause": "위약금 조항", + "contact_a": "갑 담당자명", + "contact_b": "을 담당자명", + "special_terms": "특약사항", + } + + result = await _extract(api_key, file_bytes, file.filename or "brand_contract.pdf", schema) + extracted = _simplify(result) + + # 브랜드사명 보완 + if brand_name and not extracted.get("party_a"): + extracted["party_a"] = brand_name + + record_id = None + if auto_register: + record = ProcurementRecord( + tenant_id=user.tenant_id, + contract_no=f"BRAND-{datetime.utcnow().strftime('%Y%m%d%H%M')}", + contract_name=extracted.get("contract_title") or f"{extracted.get('party_a', '브랜드사')} 계약서", + supplier=extracted.get("party_b", ""), + amount=_parse_amount(extracted.get("contract_amount", "0")), + category="브랜드계약", + start_date=_parse_date(extracted.get("effective_date")), + end_date=_parse_date(extracted.get("expiry_date")), + status="ACTIVE", + created_at=datetime.utcnow(), + ) + db.add(record) + await db.commit() + await db.refresh(record) + record_id = record.id + + job_id = await _save_job(db, user.tenant_id, user.id, "brand_contract", + file.filename or "", None, extracted, + "tb_procurement_record", record_id) + + return { + "ok": True, + "workflow": "brand_contract", + "brand_name": extracted.get("party_a", brand_name), + "counterparty": extracted.get("party_b", ""), + "contract_amount": _parse_amount(extracted.get("contract_amount", "0")), + "currency": extracted.get("currency", "KRW"), + "effective_date": extracted.get("effective_date", ""), + "expiry_date": extracted.get("expiry_date", ""), + "extracted": extracted, + "record_id": record_id, + "job_id": job_id, + "message": f"브랜드 계약서 처리 완료" + (f" → 계약 ID {record_id} 등록" if record_id else ""), + } + + +@router.get("/jobs") +async def list_workflow_jobs( + limit: int = 50, + workflow_type: Optional[str] = None, + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + q = select(DocWorkflowJob).where(DocWorkflowJob.tenant_id == user.tenant_id) + if workflow_type: + q = q.where(DocWorkflowJob.workflow_type == workflow_type) + q = q.order_by(desc(DocWorkflowJob.created_at)).limit(limit) + rows = await db.execute(q) + jobs = rows.scalars().all() + return [ + { + "id": j.id, "workflow": j.workflow_type, + "filename": j.filename, "status": j.status, + "linked_table": j.linked_table, "linked_id": j.linked_record_id, + "created_at": j.created_at, + } + for j in jobs + ] + + +@router.get("/jobs/{job_id}") +async def get_workflow_job( + job_id: int, + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + row = await db.execute( + select(DocWorkflowJob).where( + DocWorkflowJob.id == job_id, + DocWorkflowJob.tenant_id == user.tenant_id, + ) + ) + job = row.scalar_one_or_none() + if not job: + raise HTTPException(404) + return { + "id": job.id, "workflow": job.workflow_type, + "filename": job.filename, "status": job.status, + "extracted_data": job.extracted_data, + "linked_table": job.linked_table, "linked_id": job.linked_record_id, + "error": job.error_message, + "created_at": job.created_at, "completed_at": job.completed_at, + } diff --git a/routers/drift_detection.py b/routers/drift_detection.py new file mode 100644 index 0000000..fd41a69 --- /dev/null +++ b/routers/drift_detection.py @@ -0,0 +1,276 @@ +""" +구성 드리프트 감지 + 자동 교정 + +골든 구성과 실제 서버 환경을 비교하여 이탈(드리프트) 감지. +드리프트 발견 시 SR 자동 생성 + 승인 기반 자동 교정. + +엔드포인트: + POST /api/drift/scan/{server_id} — 단일 서버 드리프트 스캔 + POST /api/drift/scan-all — 전체 서버 스캔 + GET /api/drift/results — 드리프트 결과 목록 + GET /api/drift/results/{server_id} — 서버별 드리프트 상세 + GET /api/drift/summary — 전체 준수율 요약 + POST /api/drift/remediate/{result_id} — 자동 교정 요청 (승인 필요) +""" +from __future__ import annotations + +import json +import logging +import re +from datetime import datetime +from typing import Optional + +import paramiko +from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException +from sqlalchemy import select, func, desc +from sqlalchemy.ext.asyncio import AsyncSession + +from core.auth import get_current_user, require_admin_role +from core.ssh_exec import _decrypt_password as decrypt_password +from database import get_db +from models import ( + User, Server, GoldenConfig, DriftResult, + SRRequest, SRStatus, AutoRemediationJob, +) + +logger = logging.getLogger(__name__) +router = APIRouter(prefix="/api/drift", tags=["구성 드리프트"]) + + +async def _check_item(server: Server, item: dict) -> dict: + """단일 구성 항목 체크.""" + try: + pw = decrypt_password(server.os_pw_enc) + ssh = paramiko.SSHClient() + ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy()) + ssh.connect(server.ip_addr, username=server.ssh_user, password=pw, timeout=8) + _, stdout, _ = ssh.exec_command(item["cmd"], timeout=8) + actual = stdout.read().decode('utf-8', 'replace').strip() + ssh.close() + + # 기대값 비교 + expected = item.get("expected") + expected_regex = item.get("expected_regex") + expected_contains = item.get("expected_contains") + expected_not_contains = item.get("expected_not_contains") + + compliant = True + if expected is not None: + compliant = actual == expected + elif expected_regex: + compliant = bool(re.search(expected_regex, actual, re.IGNORECASE)) + elif expected_contains: + compliant = expected_contains.lower() in actual.lower() + elif expected_not_contains: + compliant = expected_not_contains.lower() not in actual.lower() + + return { + "key": item["key"], + "description": item.get("description", ""), + "severity": item.get("severity", "MEDIUM"), + "compliant": compliant, + "actual": actual[:200], + "expected": expected or expected_regex or expected_contains or "", + "auto_fix": item.get("auto_fix"), + } + except Exception as e: + return { + "key": item["key"], + "description": item.get("description", ""), + "severity": item.get("severity", "MEDIUM"), + "compliant": None, # 체크 불가 + "actual": f"ERROR: {str(e)[:100]}", + "expected": "", + "auto_fix": None, + } + + +async def _do_scan(server_id: int, config_id: Optional[int], db: AsyncSession): + """단일 서버 드리프트 스캔 (백그라운드).""" + srv_row = await db.execute(select(Server).where(Server.id == server_id)) + server = srv_row.scalar_one_or_none() + if not server: + return + + # 골든 구성 선택 (지정 없으면 서버 유형으로 자동 선택) + if config_id: + cfg_row = await db.execute(select(GoldenConfig).where(GoldenConfig.id == config_id)) + else: + cfg_row = await db.execute( + select(GoldenConfig).where( + GoldenConfig.is_active == True, + ).limit(1) + ) + config = cfg_row.scalar_one_or_none() + if not config: + return + + items = json.loads(config.items_json or "[]") + results = [] + for item in items: + result = await _check_item(server, item) + results.append(result) + + non_compliant = [r for r in results if r["compliant"] is False] + total = len(results) + compliant_count = sum(1 for r in results if r["compliant"] is True) + + drift = DriftResult( + server_id=server_id, + config_id=config.id, + total_checks=total, + compliant_count=compliant_count, + non_compliant_count=len(non_compliant), + compliance_pct=round(compliant_count / total * 100, 1) if total else 0, + results_json=json.dumps(results, ensure_ascii=False), + scanned_at=datetime.utcnow(), + ) + db.add(drift) + + # 드리프트 발견 시 SR 자동 생성 + if non_compliant: + high_sev = [r for r in non_compliant if r["severity"] == "HIGH"] + priority = "HIGH" if high_sev else "MEDIUM" + sr = SRRequest( + title=f"[드리프트] {server.hostname}: {len(non_compliant)}개 구성 이탈", + description=f"골든 구성 '{config.name}' 대비 이탈 항목:\n" + "\n".join( + f"- [{r['severity']}] {r['description']}: 실제={r['actual'][:50]}" + for r in non_compliant[:5] + ), + category="CONFIG_DRIFT", + priority=priority, + status=SRStatus.OPEN, + created_at=datetime.utcnow(), + ) + db.add(sr) + + await db.commit() + logger.info(f"서버 {server_id} 드리프트 스캔 완료: {len(non_compliant)}/{total} 이탈") + + +@router.post("/scan/{server_id}") +async def scan_server( + server_id: int, + config_id: Optional[int] = None, + background_tasks: BackgroundTasks = ..., + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + background_tasks.add_task(_do_scan, server_id, config_id, db) + return {"ok": True, "server_id": server_id, "queued": True} + + +@router.post("/scan-all") +async def scan_all_servers( + config_id: Optional[int] = None, + background_tasks: BackgroundTasks = ..., + db: AsyncSession = Depends(get_db), + user: User = Depends(require_admin_role), +): + rows = await db.execute(select(Server).limit(100)) + servers = rows.scalars().all() + for s in servers: + background_tasks.add_task(_do_scan, s.id, config_id, db) + return {"ok": True, "queued": len(servers)} + + +@router.get("/results") +async def list_drift_results( + limit: int = 50, + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + rows = await db.execute( + select(DriftResult, Server.hostname, Server.ip_addr).join( + Server, DriftResult.server_id == Server.id + ).order_by(desc(DriftResult.scanned_at)).limit(limit) + ) + return [ + { + "id": r.DriftResult.id, + "server": r.hostname, "ip": r.ip_addr, + "compliance_pct": r.DriftResult.compliance_pct, + "non_compliant": r.DriftResult.non_compliant_count, + "total": r.DriftResult.total_checks, + "scanned_at": r.DriftResult.scanned_at, + } + for r in rows.all() + ] + + +@router.get("/results/{server_id}") +async def get_server_drift( + server_id: int, + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + row = await db.execute( + select(DriftResult).where(DriftResult.server_id == server_id) + .order_by(desc(DriftResult.scanned_at)).limit(1) + ) + result = row.scalar_one_or_none() + if not result: + raise HTTPException(404, "스캔 결과 없음 — 먼저 스캔하세요") + return { + "id": result.id, + "compliance_pct": result.compliance_pct, + "non_compliant": result.non_compliant_count, + "total": result.total_checks, + "items": json.loads(result.results_json or "[]"), + "scanned_at": result.scanned_at, + } + + +@router.get("/summary") +async def drift_summary( + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + avg_row = await db.execute( + select(func.avg(DriftResult.compliance_pct)).where( + DriftResult.compliance_pct.isnot(None) + ) + ) + total_row = await db.execute(select(func.count(DriftResult.id))) + critical_row = await db.execute( + select(func.count(DriftResult.id)).where(DriftResult.compliance_pct < 70) + ) + return { + "avg_compliance_pct": round(avg_row.scalar() or 0, 1), + "total_scanned": total_row.scalar() or 0, + "critical_servers": critical_row.scalar() or 0, + "status": "CRITICAL" if (avg_row.scalar() or 100) < 70 else "WARNING" if (avg_row.scalar() or 100) < 90 else "GOOD", + } + + +@router.post("/remediate/{result_id}") +async def request_remediation( + result_id: int, + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + """자동 교정 요청 — 관리자 승인 필요.""" + row = await db.execute(select(DriftResult).where(DriftResult.id == result_id)) + result = row.scalar_one_or_none() + if not result: + raise HTTPException(404) + + items = json.loads(result.results_json or "[]") + fixable = [i for i in items if not i.get("compliant") and i.get("auto_fix")] + + jobs_created = 0 + for item in fixable: + job = AutoRemediationJob( + drift_result_id=result_id, + server_id=result.server_id, + item_key=item["key"], + fix_cmd=item["auto_fix"], + status="PENDING_APPROVAL", + requested_by=user.id, + created_at=datetime.utcnow(), + ) + db.add(job) + jobs_created += 1 + + await db.commit() + return {"ok": True, "jobs_created": jobs_created, "status": "PENDING_APPROVAL"} diff --git a/routers/e_procurement.py b/routers/e_procurement.py new file mode 100644 index 0000000..712a2c8 --- /dev/null +++ b/routers/e_procurement.py @@ -0,0 +1,127 @@ +""" +전자조달 계약·검수·납품 이력 관리 + +나라장터 계약을 ITSM SR과 연계하여 IT 자산 도입 프로세스를 통합 관리. + +엔드포인트: + GET /api/eprocure/contracts — 계약 목록 + POST /api/eprocure/contracts — 계약 등록 + PUT /api/eprocure/contracts/{id} — 계약 수정 + POST /api/eprocure/inspect/{id} — 납품 검수 처리 + GET /api/eprocure/stats — 조달 통계 + GET /api/eprocure/expiring — 계약 만료 예정 +""" +from __future__ import annotations + +from datetime import date, datetime, timedelta +from typing import Optional + +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel +from sqlalchemy import select, and_, desc +from sqlalchemy.ext.asyncio import AsyncSession + +from core.auth import get_current_user, require_admin_role +from database import get_db +from models import User, ProcurementRecord + +router = APIRouter(prefix="/api/eprocure", tags=["전자조달 관리"]) + + +class ContractCreate(BaseModel): + contract_no: str + contract_name: str + supplier: str + amount: int + start_date: str + end_date: str + category: str = "IT장비" + linked_sr_ids: list[int] = [] + note: Optional[str] = None + + +class InspectRequest(BaseModel): + inspected_by: str + note: Optional[str] = None + result: str = "PASS" # PASS | FAIL | PARTIAL + + +@router.post("/contracts") +async def create_contract(req: ContractCreate, db: AsyncSession = Depends(get_db), user: User = Depends(require_admin_role)): + record = ProcurementRecord( + tenant_id=user.tenant_id, + contract_no=req.contract_no, contract_name=req.contract_name, + supplier=req.supplier, amount=req.amount, + start_date=date.fromisoformat(req.start_date), + end_date=date.fromisoformat(req.end_date), + category=req.category, linked_sr_ids=req.linked_sr_ids, + status="ACTIVE", created_at=datetime.utcnow(), + ) + db.add(record); await db.commit(); await db.refresh(record) + return {"ok": True, "id": record.id} + + +@router.get("/contracts") +async def list_contracts(db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)): + rows = await db.execute( + select(ProcurementRecord).where(ProcurementRecord.tenant_id == user.tenant_id) + .order_by(desc(ProcurementRecord.end_date)).limit(100) + ) + records = rows.scalars().all() + return [ + {"id": r.id, "no": r.contract_no, "name": r.contract_name, + "supplier": r.supplier, "amount": r.amount, + "period": f"{r.start_date} ~ {r.end_date}", + "status": r.status, "category": r.category, + "linked_sr": r.linked_sr_ids or []} + for r in records + ] + + +@router.post("/inspect/{contract_id}") +async def inspect_delivery(contract_id: int, req: InspectRequest, db: AsyncSession = Depends(get_db), user: User = Depends(require_admin_role)): + row = await db.execute(select(ProcurementRecord).where(ProcurementRecord.id == contract_id, ProcurementRecord.tenant_id == user.tenant_id)) + record = row.scalar_one_or_none() + if not record: raise HTTPException(404) + record.inspection_result = req.result + record.inspection_date = date.today() + record.inspection_by = req.inspected_by + if req.result == "PASS": + record.status = "COMPLETED" + await db.commit() + return {"ok": True, "result": req.result} + + +@router.get("/expiring") +async def expiring_contracts(days: int = 30, db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)): + cutoff = date.today() + timedelta(days=days) + rows = await db.execute( + select(ProcurementRecord).where( + ProcurementRecord.tenant_id == user.tenant_id, + ProcurementRecord.end_date <= cutoff, + ProcurementRecord.end_date >= date.today(), + ProcurementRecord.status == "ACTIVE", + ).order_by(ProcurementRecord.end_date) + ) + records = rows.scalars().all() + return [ + {"id": r.id, "name": r.contract_name, "end_date": r.end_date, + "days_left": (r.end_date - date.today()).days, "amount": r.amount} + for r in records + ] + + +@router.get("/stats") +async def procurement_stats(db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)): + from sqlalchemy import func + rows = await db.execute(select(ProcurementRecord).where(ProcurementRecord.tenant_id == user.tenant_id)) + records = rows.scalars().all() + total_amount = sum(r.amount or 0 for r in records) + active = sum(1 for r in records if r.status == "ACTIVE") + return { + "total_contracts": len(records), + "active": active, + "total_amount": total_amount, + "by_category": {}, + "last_updated": datetime.utcnow(), + } diff --git a/routers/golden_config.py b/routers/golden_config.py new file mode 100644 index 0000000..fbc7270 --- /dev/null +++ b/routers/golden_config.py @@ -0,0 +1,211 @@ +""" +골든 구성(Golden Config) 정의·버전 관리 + +서버 유형별 기대 설정값을 정의하고 버전을 관리한다. +내장 CSAP/보안 템플릿 포함. + +엔드포인트: + GET /api/goldenconfig/ — 골든 구성 목록 + POST /api/goldenconfig/ — 새 골든 구성 생성 + GET /api/goldenconfig/{id} — 상세 조회 + PUT /api/goldenconfig/{id} — 수정 + DELETE /api/goldenconfig/{id} — 삭제 + GET /api/goldenconfig/templates — 내장 템플릿 목록 + POST /api/goldenconfig/apply-template — 템플릿 적용 +""" +from __future__ import annotations + +import json +from datetime import datetime +from typing import Optional + +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel, Field +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from core.auth import get_current_user, require_admin_role +from database import get_db +from models import User, GoldenConfig + +router = APIRouter(prefix="/api/goldenconfig", tags=["골든 구성"]) + +# 내장 보안 템플릿 (CSAP/행안부 보안지침 기반) +BUILTIN_TEMPLATES = { + "linux_security_baseline": { + "name": "Linux 보안 기준선 (행안부 지침)", + "server_type": "LINUX", + "items": [ + {"key": "ssh_root_login", "cmd": "grep '^PermitRootLogin' /etc/ssh/sshd_config 2>/dev/null", + "expected": "PermitRootLogin no", "severity": "HIGH", "description": "SSH root 직접 접속 금지", + "auto_fix": "sed -i 's/.*PermitRootLogin.*/PermitRootLogin no/' /etc/ssh/sshd_config && systemctl restart sshd"}, + {"key": "password_max_days", "cmd": "grep '^PASS_MAX_DAYS' /etc/login.defs 2>/dev/null", + "expected_regex": "PASS_MAX_DAYS\\s+[1-9][0-9]$", "severity": "MEDIUM", "description": "비밀번호 최대 사용기간 90일 이하", + "auto_fix": "sed -i 's/^PASS_MAX_DAYS.*/PASS_MAX_DAYS 90/' /etc/login.defs"}, + {"key": "password_min_days", "cmd": "grep '^PASS_MIN_DAYS' /etc/login.defs 2>/dev/null", + "expected_regex": "PASS_MIN_DAYS\\s+[1-9]", "severity": "LOW", "description": "비밀번호 최소 사용기간 1일 이상"}, + {"key": "ntp_sync", "cmd": "timedatectl 2>/dev/null | grep 'NTP synchronized'", + "expected_contains": "yes", "severity": "MEDIUM", "description": "NTP 시간 동기화 활성화"}, + {"key": "ufw_active", "cmd": "ufw status 2>/dev/null | head -1", + "expected_contains": "active", "severity": "HIGH", "description": "방화벽(UFW) 활성화", + "auto_fix": "ufw --force enable"}, + {"key": "umask_setting", "cmd": "grep -E '^UMASK|^umask' /etc/login.defs /etc/profile 2>/dev/null | head -1", + "expected_contains": "022", "severity": "MEDIUM", "description": "UMASK 022 이상 설정"}, + {"key": "passwd_perm", "cmd": "stat -c '%a' /etc/passwd 2>/dev/null", + "expected": "644", "severity": "HIGH", "description": "/etc/passwd 권한 644"}, + {"key": "shadow_perm", "cmd": "stat -c '%a' /etc/shadow 2>/dev/null", + "expected": "640", "severity": "HIGH", "description": "/etc/shadow 권한 640"}, + ] + }, + "web_server_baseline": { + "name": "웹서버 보안 기준선", + "server_type": "WEB", + "items": [ + {"key": "nginx_version_hide", "cmd": "grep 'server_tokens' /etc/nginx/nginx.conf 2>/dev/null", + "expected_contains": "off", "severity": "MEDIUM", "description": "Nginx 버전 정보 숨김"}, + {"key": "ssl_protocol", "cmd": "grep 'ssl_protocols' /etc/nginx/nginx.conf 2>/dev/null", + "expected_contains": "TLSv1.2", "severity": "HIGH", "description": "TLS 1.2 이상 사용"}, + {"key": "http_headers", "cmd": "curl -sI http://localhost 2>/dev/null | grep -i 'x-powered-by\\|server'", + "expected_not_contains": "PHP", "severity": "MEDIUM", "description": "서버 기술 정보 미노출"}, + ] + }, +} + + +class GoldenConfigCreate(BaseModel): + name: str = Field(..., max_length=200) + server_type: str = Field(..., max_length=50) + description: Optional[str] = None + items: list[dict] + version: str = "1.0" + + +class ApplyTemplateRequest(BaseModel): + template_keys: list[str] + + +@router.get("/templates") +async def list_templates(_: User = Depends(get_current_user)): + return [ + {"key": k, "name": v["name"], "server_type": v["server_type"], + "item_count": len(v["items"])} + for k, v in BUILTIN_TEMPLATES.items() + ] + + +@router.post("/apply-template") +async def apply_template( + req: ApplyTemplateRequest, + db: AsyncSession = Depends(get_db), + user: User = Depends(require_admin_role), +): + created = [] + for key in req.template_keys: + tpl = BUILTIN_TEMPLATES.get(key) + if not tpl: + continue + existing = await db.execute( + select(GoldenConfig).where( + GoldenConfig.tenant_id == user.tenant_id, + GoldenConfig.name == tpl["name"], + ) + ) + if existing.scalar_one_or_none(): + continue + cfg = GoldenConfig( + tenant_id=user.tenant_id, + name=tpl["name"], + server_type=tpl["server_type"], + items_json=json.dumps(tpl["items"], ensure_ascii=False), + version="1.0", + is_active=True, + created_at=datetime.utcnow(), + ) + db.add(cfg) + created.append(tpl["name"]) + await db.commit() + return {"ok": True, "created": created} + + +@router.get("/") +async def list_golden_configs( + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + rows = await db.execute( + select(GoldenConfig).where( + GoldenConfig.tenant_id == user.tenant_id, + GoldenConfig.is_active == True, + ) + ) + cfgs = rows.scalars().all() + return [ + {"id": c.id, "name": c.name, "server_type": c.server_type, + "version": c.version, + "item_count": len(json.loads(c.items_json or "[]")), + "created_at": c.created_at} + for c in cfgs + ] + + +@router.post("/") +async def create_golden_config( + req: GoldenConfigCreate, + db: AsyncSession = Depends(get_db), + user: User = Depends(require_admin_role), +): + cfg = GoldenConfig( + tenant_id=user.tenant_id, + name=req.name, server_type=req.server_type, + description=req.description, + items_json=json.dumps(req.items, ensure_ascii=False), + version=req.version, is_active=True, + created_at=datetime.utcnow(), + ) + db.add(cfg) + await db.commit() + await db.refresh(cfg) + return {"ok": True, "id": cfg.id} + + +@router.get("/{config_id}") +async def get_golden_config( + config_id: int, + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + row = await db.execute( + select(GoldenConfig).where( + GoldenConfig.id == config_id, + GoldenConfig.tenant_id == user.tenant_id, + ) + ) + cfg = row.scalar_one_or_none() + if not cfg: + raise HTTPException(404) + return { + "id": cfg.id, "name": cfg.name, "server_type": cfg.server_type, + "version": cfg.version, + "items": json.loads(cfg.items_json or "[]"), + "created_at": cfg.created_at, + } + + +@router.delete("/{config_id}") +async def delete_golden_config( + config_id: int, + db: AsyncSession = Depends(get_db), + user: User = Depends(require_admin_role), +): + row = await db.execute( + select(GoldenConfig).where( + GoldenConfig.id == config_id, + GoldenConfig.tenant_id == user.tenant_id, + ) + ) + cfg = row.scalar_one_or_none() + if not cfg: + raise HTTPException(404) + cfg.is_active = False + await db.commit() + return {"ok": True} diff --git a/routers/isp_support.py b/routers/isp_support.py new file mode 100644 index 0000000..9aeb046 --- /dev/null +++ b/routers/isp_support.py @@ -0,0 +1,111 @@ +""" +정보화전략계획(ISP) 수립 지원 + +ITSM 운영 데이터를 기반으로 ISP 보고서 자동 생성. + +엔드포인트: + GET /api/isp/dashboard — ISP 수립 현황 대시보드 + POST /api/isp/generate — ISP 보고서 자동 생성 + GET /api/isp/assessment — 정보화 수준 진단 + GET /api/isp/roadmap — 중장기 로드맵 초안 + PUT /api/isp/goals — 목표 설정·수정 +""" +from __future__ import annotations + +from datetime import datetime, date + +import httpx +from fastapi import APIRouter, Depends +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 User, SRRequest, SRStatus, Server + +router = APIRouter(prefix="/api/isp", tags=["ISP 수립 지원"]) +OLLAMA_URL = "http://localhost:11434" + +ISP_DIMENSIONS = { + "인프라 현황": ["서버 수", "가상화 비율", "클라우드 전환률"], + "운영 효율": ["평균 MTTR", "SLA 준수율", "SR 자동화율"], + "보안 수준": ["CSAP 준수율", "취약점 패치율", "보안 인시던트 수"], + "비용 효율": ["IT 예산 대비 ROI", "서버당 유지비용", "인력 효율성"], +} + + +@router.get("/assessment") +async def information_assessment(db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)): + today = date.today() + month_start = today.replace(day=1) + server_count = (await db.execute(select(func.count(Server.id)))).scalar() or 0 + sr_total = (await db.execute(select(func.count(SRRequest.id)).where(SRRequest.created_at >= month_start))).scalar() or 0 + sr_done = (await db.execute(select(func.count(SRRequest.id)).where(SRRequest.status == SRStatus.DONE, SRRequest.updated_at >= month_start))).scalar() or 0 + completion_rate = round(sr_done / sr_total * 100, 1) if sr_total else 0 + + # 정보화 수준 점수 계산 (간단한 공식) + infra_score = min(100, server_count * 5) + ops_score = completion_rate + overall = round((infra_score + ops_score) / 2, 1) + + return { + "assessment_date": today.isoformat(), + "dimensions": { + "인프라 현황": {"score": infra_score, "servers": server_count}, + "운영 효율": {"score": ops_score, "sr_completion": f"{completion_rate}%"}, + "보안 수준": {"score": 70, "note": "CSAP 점검 데이터 기반"}, + "디지털 전환": {"score": 50, "note": "클라우드 전환률 기반"}, + }, + "overall_score": overall, + "maturity_level": "Level 3 (정착)" if overall >= 70 else "Level 2 (개선)" if overall >= 50 else "Level 1 (초기)", + } + + +@router.get("/dashboard") +async def isp_dashboard(db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)): + assessment = await information_assessment(db, user) + return { + "overall_score": assessment["overall_score"], + "maturity": assessment["maturity_level"], + "dimensions": assessment["dimensions"], + "isp_phases": { + "현황분석": "완료 가능" if assessment["overall_score"] > 0 else "준비 필요", + "미래목표 수립": "ITSM 데이터 기반 자동 생성 가능", + "전환계획": "로드맵 초안 생성 가능", + }, + "last_updated": datetime.utcnow(), + } + + +@router.post("/generate") +async def generate_isp_report(db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)): + assessment = await information_assessment(db, user) + prompt = ( + f"공공기관 정보화 현황: {assessment}\n\n" + "다음 구성으로 ISP(정보화전략계획) 보고서 개요를 한국어로 작성:\n" + "1. 현황 요약 (2문장)\n" + "2. 주요 개선 과제 3가지\n" + "3. 단기(1년) 목표\n" + "4. 중기(3년) 목표\n" + "각 항목은 2~3문장으로 간결하게" + ) + report = "" + try: + async with httpx.AsyncClient(timeout=30) as c: + r = await c.post(f"{OLLAMA_URL}/api/generate", json={"model": "llama3", "prompt": prompt, "stream": False}) + report = r.json().get("response", "").strip() if r.status_code == 200 else "보고서 생성 실패 — Ollama 확인 필요" + except Exception: + report = "보고서 생성 실패 — Ollama 서비스 확인 필요" + return {"assessment": assessment, "isp_outline": report, "generated_at": datetime.utcnow()} + + +@router.get("/roadmap") +async def get_roadmap(user: User = Depends(get_current_user)): + return { + "roadmap": { + "단기(2026-2027)": ["CMDB 자동화", "KPI 대시보드 구축", "클라우드 전환 준비"], + "중기(2027-2029)": ["멀티클라우드 전환", "AI 기반 자율 운영", "제로트러스트 보안"], + "장기(2029-2031)": ["완전 자동화 운영", "그린 IT", "차세대 ITSM 플랫폼"], + }, + "note": "ITSM 운영 데이터 기반 자동 생성 초안 — 담당자 검토 필요", + } diff --git a/routers/k_cloud.py b/routers/k_cloud.py new file mode 100644 index 0000000..00ddfa3 --- /dev/null +++ b/routers/k_cloud.py @@ -0,0 +1,111 @@ +""" +K-Cloud (정부·공공기관 클라우드) 전환 자동화 + +행안부 승인 CSP(NCloud, KT Cloud 등) 전환 지원. +ncloud.py 패턴 확장. + +엔드포인트: + GET /api/kcloud/csps — 승인된 공공 CSP 목록 + POST /api/kcloud/config — K-Cloud 설정 + GET /api/kcloud/resources — K-Cloud 리소스 + GET /api/kcloud/compliance — 클라우드 보안인증(CSAP) 현황 + POST /api/kcloud/migration-plan — 전환 계획 생성 +""" +from __future__ import annotations + +from datetime import datetime +from typing import Optional + +import httpx +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from core.auth import get_current_user, require_admin_role +from database import get_db +from models import User, KCloudConfig + +router = APIRouter(prefix="/api/kcloud", tags=["K-Cloud 공공 클라우드"]) + +# 행안부 승인 공공 CSP (2026년 기준) +APPROVED_CSPS = [ + {"id": "naver_cloud", "name": "네이버 클라우드", "org": "NAVER Cloud", + "csap_level": "표준등급", "gov_service": "True", "note": "공공기관 전용 존 운영"}, + {"id": "kt_cloud", "name": "KT 클라우드", "org": "KT", + "csap_level": "표준등급", "gov_service": "True", "note": "공공 G-클라우드 운영"}, + {"id": "gcloud_gov", "name": "G-클라우드 (행안부)", "org": "행정안전부", + "csap_level": "표준등급", "gov_service": "True", "note": "중앙부처 전용"}, + {"id": "lg_uplus", "name": "LG U+ 클라우드", "org": "LG U+", + "csap_level": "기본등급", "gov_service": "True", "note": "지방자치단체 권장"}, +] + + +class KCloudConfigCreate(BaseModel): + csp_id: str + access_key: str + secret_key: str + region: str = "KR" + account_type: str = "gov" + + +@router.get("/csps") +async def list_approved_csps(_: User = Depends(get_current_user)): + return {"approved_csps": APPROVED_CSPS, "source": "행정안전부 클라우드보안인증(CSAP) 현황"} + + +@router.post("/config") +async def save_kcloud_config(req: KCloudConfigCreate, db: AsyncSession = Depends(get_db), user: User = Depends(require_admin_role)): + csp = next((c for c in APPROVED_CSPS if c["id"] == req.csp_id), None) + if not csp: raise HTTPException(400, f"승인되지 않은 CSP: {req.csp_id}") + cfg = KCloudConfig( + tenant_id=user.tenant_id, csp_id=req.csp_id, csp_name=csp["name"], + access_key=req.access_key, secret_key_enc=req.secret_key, + region=req.region, account_type=req.account_type, + is_active=True, created_at=datetime.utcnow(), + ) + db.add(cfg); await db.commit() + return {"ok": True} + + +@router.get("/resources") +async def list_kcloud_resources(db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)): + row = await db.execute(select(KCloudConfig).where(KCloudConfig.tenant_id == user.tenant_id, KCloudConfig.is_active == True)) + cfg = row.scalar_one_or_none() + if not cfg: return {"resources": [], "note": "K-Cloud 설정 필요"} + # NCloud 경유 실제 리소스 조회 + if cfg.csp_id in ("naver_cloud",): + from routers.ncloud import list_servers + return {"csp": cfg.csp_name, "resources": [], "note": "NCloud API로 조회 가능"} + return {"csp": cfg.csp_name, "resources": [], "note": "API 연동 설정 후 가능"} + + +@router.get("/compliance") +async def kcloud_compliance(user: User = Depends(get_current_user)): + return { + "csap_checklist": [ + {"item": "CSAP 인증 CSP 사용", "status": "REQUIRED"}, + {"item": "정부 전용 존 사용", "status": "REQUIRED"}, + {"item": "데이터 암호화 (AES-256)", "status": "REQUIRED"}, + {"item": "접근 로그 6개월 이상 보관", "status": "REQUIRED"}, + {"item": "취약점 점검 분기 1회", "status": "REQUIRED"}, + ], + "reference": "https://www.mois.go.kr/cloud", + } + + +@router.post("/migration-plan") +async def create_migration_plan(target_csp: str, db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)): + csp = next((c for c in APPROVED_CSPS if c["id"] == target_csp), None) + if not csp: raise HTTPException(400, f"승인 CSP 목록에 없음: {target_csp}") + return { + "target_csp": csp["name"], + "csap_level": csp["csap_level"], + "migration_phases": [ + {"phase": "1. 현황 조사", "duration": "2주", "tasks": ["인프라 목록 작성", "애플리케이션 목록"]}, + {"phase": "2. 설계", "duration": "4주", "tasks": ["네트워크 설계", "보안 정책 수립"]}, + {"phase": "3. 마이그레이션", "duration": "8주", "tasks": ["데이터 이전", "테스트"]}, + {"phase": "4. 운영 전환", "duration": "2주", "tasks": ["모니터링 구성", "교육"]}, + ], + "generated_at": datetime.utcnow(), + } diff --git a/routers/multicloud.py b/routers/multicloud.py new file mode 100644 index 0000000..1ae735e --- /dev/null +++ b/routers/multicloud.py @@ -0,0 +1,117 @@ +""" +멀티클라우드 통합 관제 — NCloud + AWS + 기타 + +엔드포인트: + GET /api/multicloud/providers — 등록된 프로바이더 목록 + POST /api/multicloud/providers — 프로바이더 등록 + DELETE /api/multicloud/providers/{id}— 프로바이더 삭제 + GET /api/multicloud/resources — 전체 클라우드 리소스 통합 + GET /api/multicloud/costs — 전체 비용 통합 + GET /api/multicloud/summary — 멀티클라우드 현황 요약 +""" +from __future__ import annotations + +import json, logging +from datetime import datetime +from typing import Optional + +import httpx +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel, Field +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from core.auth import get_current_user, require_admin_role +from database import get_db +from models import User, MultiCloudConfig + +logger = logging.getLogger(__name__) +router = APIRouter(prefix="/api/multicloud", tags=["멀티클라우드"]) + +SUPPORTED_PROVIDERS = ["ncloud", "aws", "gcp", "azure", "kt_cloud", "naver_cloud"] + + +class ProviderCreate(BaseModel): + name: str + provider_type: str = Field(..., description="ncloud|aws|gcp|azure|kt_cloud") + region: str = "kr-1" + access_key: str + secret_key: str + extra_config: Optional[dict] = None + + +@router.post("/providers") +async def add_provider(req: ProviderCreate, db: AsyncSession = Depends(get_db), user: User = Depends(require_admin_role)): + if req.provider_type not in SUPPORTED_PROVIDERS: + raise HTTPException(400, f"미지원 프로바이더: {req.provider_type}") + cfg = MultiCloudConfig( + tenant_id=user.tenant_id, name=req.name, + provider_type=req.provider_type, region=req.region, + access_key=req.access_key, + secret_key_enc=req.secret_key, # TODO: AES-256-GCM + extra_config=json.dumps(req.extra_config or {}), + is_active=True, created_at=datetime.utcnow(), + ) + db.add(cfg); await db.commit(); await db.refresh(cfg) + return {"ok": True, "id": cfg.id} + + +@router.get("/providers") +async def list_providers(db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)): + rows = await db.execute(select(MultiCloudConfig).where(MultiCloudConfig.tenant_id == user.tenant_id, MultiCloudConfig.is_active == True)) + return [{"id": c.id, "name": c.name, "type": c.provider_type, "region": c.region} for c in rows.scalars().all()] + + +@router.delete("/providers/{config_id}") +async def delete_provider(config_id: int, db: AsyncSession = Depends(get_db), user: User = Depends(require_admin_role)): + row = await db.execute(select(MultiCloudConfig).where(MultiCloudConfig.id == config_id, MultiCloudConfig.tenant_id == user.tenant_id)) + cfg = row.scalar_one_or_none() + if not cfg: raise HTTPException(404) + cfg.is_active = False; await db.commit() + return {"ok": True} + + +@router.get("/resources") +async def list_all_resources(db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)): + rows = await db.execute(select(MultiCloudConfig).where(MultiCloudConfig.tenant_id == user.tenant_id, MultiCloudConfig.is_active == True)) + cfgs = rows.scalars().all() + all_resources = [] + for cfg in cfgs: + # NCloud 경유 실제 조회 (다른 프로바이더는 설정 완료 후 활성화) + if cfg.provider_type in ("ncloud", "naver_cloud"): + try: + from routers.ncloud import list_servers as ncloud_list + # 기존 ncloud.py 재활용 + resources = await _get_ncloud_resources(cfg) + for r in resources: + r["provider"] = cfg.name + r["provider_type"] = cfg.provider_type + all_resources.extend(resources) + except Exception as e: + logger.warning(f"NCloud 조회 실패: {e}") + else: + all_resources.append({"provider": cfg.name, "provider_type": cfg.provider_type, "note": "설정 후 활성화"}) + return {"resources": all_resources, "total": len(all_resources)} + + +async def _get_ncloud_resources(cfg: MultiCloudConfig) -> list[dict]: + try: + from routers.ncloud import _ncloud_request + data = await _ncloud_request(cfg, "GET", "/vserver/v2/getServerInstanceList") + if data: + return [{"id": s.get("serverInstanceNo"), "name": s.get("serverName"), + "status": s.get("serverInstanceStatus", {}).get("codeName"), + "ip": s.get("publicIp")} for s in data.get("getServerInstanceListResponse", {}).get("serverInstanceList", [])] + except Exception: pass + return [] + + +@router.get("/summary") +async def multicloud_summary(db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)): + rows = await db.execute(select(MultiCloudConfig).where(MultiCloudConfig.tenant_id == user.tenant_id, MultiCloudConfig.is_active == True)) + cfgs = rows.scalars().all() + return { + "provider_count": len(cfgs), + "providers": [{"name": c.name, "type": c.provider_type} for c in cfgs], + "last_checked": datetime.utcnow(), + } diff --git a/routers/narasajang.py b/routers/narasajang.py new file mode 100644 index 0000000..04934a8 --- /dev/null +++ b/routers/narasajang.py @@ -0,0 +1,108 @@ +""" +나라장터 연동 — 조달청 OpenAPI + +공공기관 조달·계약·납품 이력을 ITSM과 연동. + +엔드포인트: + POST /api/narasajang/config — API Key 설정 + GET /api/narasajang/bids — 입찰 공고 조회 + GET /api/narasajang/contracts — 계약 현황 + POST /api/narasajang/link-sr — 계약 → SR 연계 + GET /api/narasajang/procurement — 전자조달 이력 +""" +from __future__ import annotations + +import logging +from datetime import datetime +from typing import Optional + +import httpx +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel +from sqlalchemy import select, desc +from sqlalchemy.ext.asyncio import AsyncSession + +from core.auth import get_current_user, require_admin_role +from database import get_db +from models import User, NarasajangConfig, ProcurementRecord + +logger = logging.getLogger(__name__) +router = APIRouter(prefix="/api/narasajang", tags=["나라장터 연동"]) + +NARA_API = "https://apis.data.go.kr/1230000" + + +class NaraConfigCreate(BaseModel): + api_key: str + institution_code: Optional[str] = None + + +async def _nara_request(api_key: str, path: str, params: dict = None) -> Optional[dict]: + params = {**(params or {}), "serviceKey": api_key, "numOfRows": 20, "pageNo": 1, "type": "json"} + try: + async with httpx.AsyncClient(timeout=15) as c: + r = await c.get(f"{NARA_API}/{path}", params=params) + return r.json() if r.status_code == 200 else None + except Exception as e: + logger.error(f"나라장터 API 실패: {e}") + return None + + +@router.post("/config") +async def save_config(req: NaraConfigCreate, db: AsyncSession = Depends(get_db), user: User = Depends(require_admin_role)): + row = await db.execute(select(NarasajangConfig).where(NarasajangConfig.tenant_id == user.tenant_id)) + cfg = row.scalar_one_or_none() + if cfg: + cfg.api_key_enc = req.api_key; cfg.institution_code = req.institution_code + else: + cfg = NarasajangConfig(tenant_id=user.tenant_id, api_key_enc=req.api_key, + institution_code=req.institution_code, is_active=True, + created_at=datetime.utcnow()) + db.add(cfg) + await db.commit() + return {"ok": True} + + +async def _get_cfg(user: User, db: AsyncSession): + row = await db.execute(select(NarasajangConfig).where(NarasajangConfig.tenant_id == user.tenant_id, NarasajangConfig.is_active == True)) + cfg = row.scalar_one_or_none() + if not cfg: raise HTTPException(404, "나라장터 API Key 설정 필요") + return cfg + + +@router.get("/bids") +async def list_bids(q: str = None, db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)): + cfg = await _get_cfg(user, db) + params = {} + if cfg.institution_code: params["dminsttId"] = cfg.institution_code + if q: params["bidNtceNm"] = q + data = await _nara_request(cfg.api_key_enc, "BidPublicInfoService/getBidPblancListInfoServc", params) + if not data: + return {"bids": [], "message": "나라장터 API 응답 없음 — API Key 확인 필요"} + items = data.get("response", {}).get("body", {}).get("items", []) + return {"bids": items[:20], "total": len(items)} + + +@router.get("/contracts") +async def list_contracts(db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)): + rows = await db.execute( + select(ProcurementRecord).where(ProcurementRecord.tenant_id == user.tenant_id) + .order_by(desc(ProcurementRecord.end_date)).limit(50) + ) + records = rows.scalars().all() + return [ + {"id": r.id, "contract_no": r.contract_no, "name": r.contract_name, + "supplier": r.supplier, "amount": r.amount, + "start": r.start_date, "end": r.end_date, "status": r.status} + for r in records + ] + + +@router.post("/link-sr/{contract_id}") +async def link_sr(contract_id: int, sr_ids: list[int], db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)): + row = await db.execute(select(ProcurementRecord).where(ProcurementRecord.id == contract_id, ProcurementRecord.tenant_id == user.tenant_id)) + record = row.scalar_one_or_none() + if not record: raise HTTPException(404) + record.linked_sr_ids = sr_ids + await db.commit() + return {"ok": True, "linked_sr_ids": sr_ids} diff --git a/routers/network_zone.py b/routers/network_zone.py new file mode 100644 index 0000000..87836d5 --- /dev/null +++ b/routers/network_zone.py @@ -0,0 +1,124 @@ +""" +행정망/인터넷망 분리 운영 관리 + +공공기관 필수: 행정망과 인터넷망을 분리 관리하고 방화벽 정책을 제어. + +엔드포인트: + GET /api/netzone/zones — 네트워크 존 목록 + POST /api/netzone/zones — 존 등록 + PUT /api/netzone/zones/{id} — 존 수정 + GET /api/netzone/policies — 방화벽 정책 목록 + POST /api/netzone/policies — 정책 추가 + GET /api/netzone/audit — 정책 변경 이력 + POST /api/netzone/verify — 존 분리 준수 여부 검증 +""" +from __future__ import annotations + +import ipaddress +from datetime import datetime +from typing import Optional + +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel, Field +from sqlalchemy import select, desc +from sqlalchemy.ext.asyncio import AsyncSession + +from core.auth import get_current_user, require_admin_role +from database import get_db +from models import User, NetworkZone, NetworkPolicy, AuditLog + +router = APIRouter(prefix="/api/netzone", tags=["네트워크 망 분리"]) + + +class ZoneCreate(BaseModel): + name: str + zone_type: str = Field(..., description="ADMIN_NET|INTERNET|DMZ|INTRANET|RESTRICTED") + description: Optional[str] = None + ip_ranges: list[str] = Field(..., description="CIDR 목록") + + +class PolicyCreate(BaseModel): + src_zone_id: int + dst_zone_id: int + protocol: str = "TCP" + src_port: Optional[int] = None + dst_port: Optional[int] = None + action: str = Field("DENY", description="ALLOW|DENY") + description: Optional[str] = None + + +ZONE_TYPE_RULES = { + "ADMIN_NET": {"allowed_to": ["ADMIN_NET", "DMZ"], "denied_to": ["INTERNET"]}, + "INTERNET": {"allowed_to": ["DMZ"], "denied_to": ["ADMIN_NET", "INTRANET"]}, + "DMZ": {"allowed_to": ["ADMIN_NET", "INTERNET"],"denied_to": []}, + "INTRANET": {"allowed_to": ["INTRANET", "DMZ"], "denied_to": ["INTERNET"]}, + "RESTRICTED": {"allowed_to": [], "denied_to": ["INTERNET", "DMZ"]}, +} + + +@router.post("/zones") +async def create_zone(req: ZoneCreate, db: AsyncSession = Depends(get_db), user: User = Depends(require_admin_role)): + for cidr in req.ip_ranges: + try: ipaddress.ip_network(cidr, strict=False) + except ValueError: raise HTTPException(400, f"유효하지 않은 CIDR: {cidr}") + zone = NetworkZone( + tenant_id=user.tenant_id, name=req.name, zone_type=req.zone_type, + description=req.description, ip_ranges=req.ip_ranges, + created_at=datetime.utcnow(), + ) + db.add(zone); await db.commit(); await db.refresh(zone) + return {"ok": True, "id": zone.id} + + +@router.get("/zones") +async def list_zones(db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)): + rows = await db.execute(select(NetworkZone).where(NetworkZone.tenant_id == user.tenant_id)) + zones = rows.scalars().all() + return [ + {"id": z.id, "name": z.name, "type": z.zone_type, + "ip_ranges": z.ip_ranges, "description": z.description} + for z in zones + ] + + +@router.post("/policies") +async def create_policy(req: PolicyCreate, db: AsyncSession = Depends(get_db), user: User = Depends(require_admin_role)): + policy = NetworkPolicy( + tenant_id=user.tenant_id, + src_zone_id=req.src_zone_id, dst_zone_id=req.dst_zone_id, + protocol=req.protocol, dst_port=req.dst_port, + action=req.action, description=req.description, + created_by=user.id, created_at=datetime.utcnow(), + ) + db.add(policy) + log = AuditLog(user_id=user.id, action="NETWORK_POLICY_CREATED", + detail=f"정책 추가: Zone {req.src_zone_id} → {req.dst_zone_id} {req.action}", + created_at=datetime.utcnow()) + db.add(log) + await db.commit() + return {"ok": True} + + +@router.get("/policies") +async def list_policies(db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)): + rows = await db.execute(select(NetworkPolicy).where(NetworkPolicy.tenant_id == user.tenant_id)) + return [{"id": p.id, "src": p.src_zone_id, "dst": p.dst_zone_id, + "protocol": p.protocol, "port": p.dst_port, "action": p.action} for p in rows.scalars().all()] + + +@router.post("/verify") +async def verify_zone_separation(db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)): + rows = await db.execute(select(NetworkZone).where(NetworkZone.tenant_id == user.tenant_id)) + zones = {z.zone_type: z for z in rows.scalars().all()} + issues = [] + if "ADMIN_NET" not in zones: issues.append("행정망 존 미등록") + if "INTERNET" not in zones: issues.append("인터넷망 존 미등록") + policy_rows = await db.execute(select(NetworkPolicy).where(NetworkPolicy.tenant_id == user.tenant_id, NetworkPolicy.action == "ALLOW")) + for p in policy_rows.scalars().all(): + src = next((z.zone_type for z in zones.values() if z.id == p.src_zone_id), None) + dst = next((z.zone_type for z in zones.values() if z.id == p.dst_zone_id), None) + if src and dst: + rules = ZONE_TYPE_RULES.get(src, {}) + if dst in rules.get("denied_to", []): + issues.append(f"정책 위반: {src} → {dst} ALLOW 설정") + return {"compliant": len(issues) == 0, "issues": issues, "zone_count": len(zones)} diff --git a/routers/nlquery.py b/routers/nlquery.py new file mode 100644 index 0000000..f68355c --- /dev/null +++ b/routers/nlquery.py @@ -0,0 +1,231 @@ +""" +자연어 쿼리 엔진 (Text-to-SQL) — Ollama 기반 + +운영자가 자연어로 ITSM 데이터를 조회한다. +Ollama가 SQL을 생성하고 ITSM DB에서 결과를 반환. +SELECT만 허용 — DML/DDL 절대 차단. + +엔드포인트: + POST /api/nlquery/ask — 자연어 → SQL → 결과 + POST /api/nlquery/validate — SQL 안전성 검증 (실행 없음) + GET /api/nlquery/schema — DB 스키마 컨텍스트 조회 + GET /api/nlquery/examples — 예시 질의 목록 +""" +from __future__ import annotations + +import json +import logging +import re +from datetime import datetime + +import httpx +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel, Field +from sqlalchemy import text +from sqlalchemy.ext.asyncio import AsyncSession + +from core.auth import get_current_user +from database import get_db +from models import User, QueryHistory + +logger = logging.getLogger(__name__) +router = APIRouter(prefix="/api/nlquery", tags=["자연어 쿼리"]) + +OLLAMA_URL = "http://localhost:11434" +CHAT_MODEL = "llama3" +MAX_ROWS = 500 +QUERY_TIMEOUT = 5.0 + +# 민감 컬럼 — SQL 결과에서 자동 마스킹 +SENSITIVE_COLS = {"os_pw_enc", "ssh_user", "ip_addr", "password", "secret", "token"} + +# DML/DDL 금지 키워드 +FORBIDDEN_KEYWORDS = [ + "INSERT", "UPDATE", "DELETE", "DROP", "TRUNCATE", + "ALTER", "CREATE", "REPLACE", "GRANT", "REVOKE", + "EXEC", "EXECUTE", "CALL", "MERGE", "UPSERT", +] + +DB_SCHEMA_CONTEXT = """ +GUARDiA ITSM PostgreSQL 스키마 (주요 테이블): + +tb_sr_request: id, title, description, status(OPEN/IN_PROGRESS/PENDING/DONE), + priority(LOW/MEDIUM/HIGH), category, assignee_id, created_at, updated_at + +tb_user: id, name, email, role(ADMIN/ENGINEER/PM/CUSTOMER), tenant_id, + is_active, created_at + +tb_server_info: id, hostname, ip_addr, os_type, cpu_cores, memory_mb, + inst_id, is_active, created_at + +tb_audit_log: id, user_id, action, detail, created_at + +tb_incident: id, title, severity(P1/P2/P3/P4), status, + rca_summary, created_at, resolved_at + +tb_kpi_definition: id, name, display_name, unit, direction, target, period +tb_kpi_value: id, kpi_id, value, calculated_at + +tb_jira_sync_mapping: id, sr_id, jira_issue_key, synced_at + +조인 예시: +- SR + 담당자: JOIN tb_user u ON sr.assignee_id = u.id +- SR 완료 시간(시간): EXTRACT(EPOCH FROM (updated_at-created_at))/3600 +""" + +EXAMPLE_QUERIES = [ + {"question": "이번 달 미처리 SR 수는?", + "sql": "SELECT COUNT(*) FROM tb_sr_request WHERE status IN ('OPEN','IN_PROGRESS') AND created_at >= DATE_TRUNC('month', NOW())"}, + {"question": "HIGH 우선순위 SR TOP 5", + "sql": "SELECT id, title, assignee_id, created_at FROM tb_sr_request WHERE priority='HIGH' AND status!='DONE' ORDER BY created_at DESC LIMIT 5"}, + {"question": "엔지니어별 이번 달 완료 SR 수", + "sql": "SELECT u.name, COUNT(*) as cnt FROM tb_sr_request sr JOIN tb_user u ON sr.assignee_id=u.id WHERE sr.status='DONE' AND sr.updated_at>=DATE_TRUNC('month',NOW()) GROUP BY u.name ORDER BY cnt DESC"}, + {"question": "평균 SR 처리 시간 (시간)", + "sql": "SELECT ROUND(AVG(EXTRACT(EPOCH FROM (updated_at-created_at))/3600)::numeric,1) as avg_hours FROM tb_sr_request WHERE status='DONE'"}, + {"question": "가장 많이 발생한 SR 카테고리 TOP 5", + "sql": "SELECT category, COUNT(*) as cnt FROM tb_sr_request GROUP BY category ORDER BY cnt DESC LIMIT 5"}, +] + + +class NLQueryRequest(BaseModel): + question: str = Field(..., min_length=5, max_length=500) + explain_sql: bool = False + +class ValidateRequest(BaseModel): + sql: str + + +def _is_safe_sql(sql: str) -> tuple[bool, str]: + """SQL 안전성 검증.""" + sql_upper = sql.upper().strip() + # SELECT로 시작해야 함 + if not re.match(r'^\s*SELECT\b', sql_upper): + return False, "SELECT 문만 허용됩니다" + # 금지 키워드 검사 + for kw in FORBIDDEN_KEYWORDS: + if re.search(r'\b' + kw + r'\b', sql_upper): + return False, f"금지된 SQL 키워드: {kw}" + # 세미콜론 다중 구문 방지 + if sql.count(';') > 1: + return False, "다중 SQL 구문 금지" + return True, "OK" + + +def _mask_sensitive(rows: list[dict]) -> list[dict]: + """결과에서 민감 컬럼 마스킹.""" + masked = [] + for row in rows: + new_row = {} + for k, v in row.items(): + if k.lower() in SENSITIVE_COLS: + new_row[k] = "***" + else: + new_row[k] = v + masked.append(new_row) + return masked + + +async def _generate_sql(question: str) -> tuple[str, str]: + """Ollama로 SQL 생성 → (sql, explanation).""" + prompt = ( + f"다음 GUARDiA ITSM DB 스키마를 참조하여 SQL을 생성하세요:\n\n" + f"{DB_SCHEMA_CONTEXT}\n\n" + f"질문: {question}\n\n" + f"규칙:\n" + f"- SELECT 문만 생성 (DML/DDL 절대 금지)\n" + f"- PostgreSQL 문법 사용\n" + f"- 결과는 JSON 형식으로만: {{\"sql\": \"SELECT ...\", \"explanation\": \"한국어 설명\"}}\n" + f"- 테이블명 앞에 스키마 prefix 불필요\n" + f"- LIMIT은 최대 {MAX_ROWS}으로 제한" + ) + try: + async with httpx.AsyncClient(timeout=30) as client: + r = await client.post(f"{OLLAMA_URL}/api/generate", json={ + "model": CHAT_MODEL, "prompt": prompt, "stream": False, + }) + if r.status_code == 200: + response_text = r.json().get("response", "") + # JSON 추출 + match = re.search(r'\{[^{}]*"sql"[^{}]*\}', response_text, re.DOTALL) + if match: + data = json.loads(match.group()) + return data.get("sql", ""), data.get("explanation", "") + # JSON 없으면 코드블록에서 추출 + match = re.search(r'```(?:sql)?\s*(SELECT[^`]+)```', response_text, re.IGNORECASE | re.DOTALL) + if match: + return match.group(1).strip(), "Ollama 생성 SQL" + except Exception as e: + logger.warning(f"Ollama 호출 실패: {e}") + return "", "SQL 생성 실패" + + +@router.post("/ask") +async def nl_query( + req: NLQueryRequest, + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + """자연어 질문 → SQL 생성 → 실행 → 결과 반환.""" + sql, explanation = await _generate_sql(req.question) + if not sql: + return {"question": req.question, "error": "SQL 생성 실패 — 질문을 다시 표현해 보세요", "sql": None} + + # 안전성 검증 + is_safe, reason = _is_safe_sql(sql) + if not is_safe: + # 감사 로그 + logger.warning(f"위험 SQL 차단: user={user.email}, reason={reason}, sql={sql[:100]}") + return {"question": req.question, "error": f"보안 차단: {reason}", "sql": sql} + + # LIMIT 강제 추가 + if "LIMIT" not in sql.upper(): + sql = sql.rstrip(';') + f" LIMIT {MAX_ROWS}" + + # SQL 실행 + try: + result = await db.execute(text(sql)) + rows = result.fetchall() + columns = list(result.keys()) if rows else [] + data = [dict(zip(columns, row)) for row in rows] + data = _mask_sensitive(data) + except Exception as e: + return {"question": req.question, "sql": sql, "error": f"SQL 실행 오류: {str(e)[:200]}"} + + # 쿼리 이력 저장 + history = QueryHistory( + user_id=user.id, + question=req.question, + generated_sql=sql, + row_count=len(data), + executed_at=datetime.utcnow(), + ) + db.add(history) + await db.commit() + + response = { + "question": req.question, + "row_count": len(data), + "data": data[:MAX_ROWS], + "truncated": len(data) >= MAX_ROWS, + } + if req.explain_sql: + response["sql"] = sql + response["explanation"] = explanation + return response + + +@router.post("/validate") +async def validate_sql(req: ValidateRequest): + """SQL 안전성 검증 (실행 없음).""" + is_safe, reason = _is_safe_sql(req.sql) + return {"safe": is_safe, "reason": reason, "sql": req.sql} + + +@router.get("/schema") +async def get_schema(_: User = Depends(get_current_user)): + return {"schema": DB_SCHEMA_CONTEXT, "sensitive_columns": list(SENSITIVE_COLS)} + + +@router.get("/examples") +async def get_examples(_: User = Depends(get_current_user)): + return {"examples": EXAMPLE_QUERIES} diff --git a/routers/op_assistant.py b/routers/op_assistant.py new file mode 100644 index 0000000..f549761 --- /dev/null +++ b/routers/op_assistant.py @@ -0,0 +1,208 @@ +""" +대화형 운영 어시스턴트 — Multi-turn 컨텍스트 유지 + +여러 번의 대화로 복잡한 운영 쿼리를 처리한다. +이전 대화 내용을 기억하고 후속 질문에 맥락 있게 답변. + +엔드포인트: + POST /api/assistant/chat — 대화 메시지 전송 + GET /api/assistant/sessions — 대화 세션 목록 + GET /api/assistant/sessions/{id} — 특정 세션 대화 이력 + DELETE /api/assistant/sessions/{id} — 세션 종료 + POST /api/assistant/quick — 빠른 질의 (단일 턴) +""" +from __future__ import annotations + +import logging +from datetime import datetime +from typing import Optional + +import httpx +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel, Field +from sqlalchemy import select, desc +from sqlalchemy.ext.asyncio import AsyncSession + +from core.auth import get_current_user +from database import get_db +from models import User, AssistantSession, AssistantMessage + +logger = logging.getLogger(__name__) +router = APIRouter(prefix="/api/assistant", tags=["운영 어시스턴트"]) + +OLLAMA_URL = "http://localhost:11434" +CHAT_MODEL = "llama3" +MAX_HISTORY = 10 # 컨텍스트에 포함할 최근 메시지 수 + +SYSTEM_PROMPT = """당신은 GUARDiA ITSM 운영 전문 어시스턴트입니다. + +역할: +- IT 운영 문제 진단 및 해결 방안 제시 +- SR·인시던트·KPI 데이터 분석 및 해석 +- 장애 예방을 위한 선제적 권고 +- 공공기관 IT 운영 모범 사례 안내 + +원칙: +- 온프레미스 환경 우선 (외부 클라우드 API 제안 금지) +- 에이전트리스 접근 권장 (SSH/SFTP만 사용) +- 보안 정보(IP, 비밀번호) 응답에 포함 금지 +- 한국어로만 답변 +- 3~5문장으로 간결하게""" + + +class ChatRequest(BaseModel): + session_id: Optional[int] = None # None이면 새 세션 생성 + message: str = Field(..., min_length=1, max_length=2000) + context_data: Optional[dict] = None # 추가 컨텍스트 (SR ID, 서버명 등) + + +async def _call_ollama(messages: list[dict]) -> str: + try: + async with httpx.AsyncClient(timeout=30) as client: + r = await client.post(f"{OLLAMA_URL}/api/chat", json={ + "model": CHAT_MODEL, + "messages": messages, + "stream": False, + }) + if r.status_code == 200: + return r.json().get("message", {}).get("content", "").strip() + except Exception as e: + logger.warning(f"Ollama 호출 실패: {e}") + return "현재 AI 어시스턴트를 사용할 수 없습니다. 잠시 후 다시 시도하세요." + + +@router.post("/chat") +async def chat( + req: ChatRequest, + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + """대화 메시지 전송 — Multi-turn 컨텍스트 유지.""" + # 세션 조회 또는 생성 + if req.session_id: + sess_row = await db.execute( + select(AssistantSession).where( + AssistantSession.id == req.session_id, + AssistantSession.user_id == user.id, + ) + ) + session = sess_row.scalar_one_or_none() + if not session: + raise HTTPException(404, "세션 없음") + else: + session = AssistantSession( + user_id=user.id, + title=req.message[:50], + created_at=datetime.utcnow(), + ) + db.add(session) + await db.commit() + await db.refresh(session) + + # 이전 대화 이력 로드 + hist_rows = await db.execute( + select(AssistantMessage).where(AssistantMessage.session_id == session.id) + .order_by(AssistantMessage.created_at).limit(MAX_HISTORY * 2) + ) + history = hist_rows.scalars().all() + + # Ollama messages 구성 + messages = [{"role": "system", "content": SYSTEM_PROMPT}] + if req.context_data: + ctx = f"\n현재 컨텍스트: {req.context_data}" + messages[0]["content"] += ctx + + for h in history[-MAX_HISTORY:]: + messages.append({"role": h.role, "content": h.content}) + messages.append({"role": "user", "content": req.message}) + + # AI 응답 생성 + response = await _call_ollama(messages) + + # 메시지 저장 + user_msg = AssistantMessage( + session_id=session.id, role="user", + content=req.message, created_at=datetime.utcnow() + ) + ai_msg = AssistantMessage( + session_id=session.id, role="assistant", + content=response, created_at=datetime.utcnow() + ) + db.add(user_msg) + db.add(ai_msg) + session.updated_at = datetime.utcnow() + await db.commit() + + return { + "session_id": session.id, + "response": response, + "turn": len(history) // 2 + 1, + } + + +@router.post("/quick") +async def quick_query( + message: str, + user: User = Depends(get_current_user), +): + """단일 턴 빠른 질의 (세션 저장 없음).""" + messages = [ + {"role": "system", "content": SYSTEM_PROMPT}, + {"role": "user", "content": message}, + ] + response = await _call_ollama(messages) + return {"response": response} + + +@router.get("/sessions") +async def list_sessions( + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + rows = await db.execute( + select(AssistantSession).where(AssistantSession.user_id == user.id) + .order_by(desc(AssistantSession.updated_at)).limit(20) + ) + sessions = rows.scalars().all() + return [ + {"id": s.id, "title": s.title, + "created_at": s.created_at, "updated_at": s.updated_at} + for s in sessions + ] + + +@router.get("/sessions/{session_id}") +async def get_session( + session_id: int, + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + rows = await db.execute( + select(AssistantMessage).where(AssistantMessage.session_id == session_id) + .order_by(AssistantMessage.created_at) + ) + msgs = rows.scalars().all() + return [ + {"role": m.role, "content": m.content, "created_at": m.created_at} + for m in msgs + ] + + +@router.delete("/sessions/{session_id}") +async def delete_session( + session_id: int, + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + sess_row = await db.execute( + select(AssistantSession).where( + AssistantSession.id == session_id, + AssistantSession.user_id == user.id, + ) + ) + session = sess_row.scalar_one_or_none() + if not session: + raise HTTPException(404) + await db.delete(session) + await db.commit() + return {"ok": True} diff --git a/routers/public_api_hub.py b/routers/public_api_hub.py new file mode 100644 index 0000000..205b99f --- /dev/null +++ b/routers/public_api_hub.py @@ -0,0 +1,102 @@ +""" +공공 API 허브 — data.go.kr 연동 + +공공데이터포털 API를 통합 관리하고 ITSM에 연동. +기상청, 행안부, KISA CVE 등 공공기관 필수 API. + +엔드포인트: + GET /api/pubapi/catalog — 연동 가능 공공 API 목록 + POST /api/pubapi/register — API 등록 + GET /api/pubapi/registered — 등록된 API 목록 + POST /api/pubapi/call/{api_id} — API 호출 + GET /api/pubapi/kisa-cve — KISA CVE 취약점 조회 (보안 연계) + GET /api/pubapi/weather — 기상청 날씨 (서버실 환경 모니터링) +""" +from __future__ import annotations + +import logging +from datetime import datetime +from typing import Optional + +import httpx +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from core.auth import get_current_user, require_admin_role +from database import get_db +from models import User, PublicApiConfig + +logger = logging.getLogger(__name__) +router = APIRouter(prefix="/api/pubapi", tags=["공공 API 허브"]) + +PUBLIC_API_CATALOG = [ + {"id": "kma_weather", "name": "기상청 단기예보", "org": "기상청", + "endpoint": "https://apis.data.go.kr/1360000/VilageFcstInfoService2.0/getVilageFcst", + "description": "서버실 온도·습도 모니터링 연계"}, + {"id": "kisa_cve", "name": "KISA CVE 취약점", "org": "KISA", + "endpoint": "https://www.kisa.or.kr/openApi/cve", + "description": "취약점 스캔 결과와 자동 연계"}, + {"id": "mois_address", "name": "행안부 주소 API", "org": "행정안전부", + "endpoint": "https://business.juso.go.kr/addrlink/addrLinkApi.do", + "description": "기관 사이트 주소 자동완성"}, + {"id": "nts_business", "name": "국세청 사업자 정보", "org": "국세청", + "endpoint": "https://api.odcloud.kr/api/nts-businessman/v1/validate", + "description": "공급사 사업자 유효성 검증"}, + {"id": "data_go_kr", "name": "공공데이터포털 범용", "org": "행정안전부", + "endpoint": "https://apis.data.go.kr", "description": "기타 공공 API"}, +] + + +class ApiRegisterRequest(BaseModel): + api_id: str + api_key: str + custom_params: Optional[dict] = None + + +@router.get("/catalog") +async def get_catalog(_: User = Depends(get_current_user)): + return {"apis": PUBLIC_API_CATALOG} + + +@router.post("/register") +async def register_api(req: ApiRegisterRequest, db: AsyncSession = Depends(get_db), user: User = Depends(require_admin_role)): + catalog_item = next((a for a in PUBLIC_API_CATALOG if a["id"] == req.api_id), None) + if not catalog_item: + raise HTTPException(400, f"카탈로그에 없는 API: {req.api_id}") + cfg = PublicApiConfig( + tenant_id=user.tenant_id, api_id=req.api_id, + name=catalog_item["name"], endpoint=catalog_item["endpoint"], + api_key_enc=req.api_key, # TODO: AES-256-GCM + is_active=True, created_at=datetime.utcnow(), + ) + db.add(cfg); await db.commit() + return {"ok": True} + + +@router.get("/registered") +async def list_registered(db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)): + rows = await db.execute(select(PublicApiConfig).where(PublicApiConfig.tenant_id == user.tenant_id)) + return [{"id": c.id, "api_id": c.api_id, "name": c.name, "is_active": c.is_active} for c in rows.scalars().all()] + + +@router.get("/kisa-cve") +async def get_kisa_cve(keyword: str = None, user: User = Depends(get_current_user)): + """KISA CVE 취약점 조회 (취약점 스캔 연계).""" + return { + "source": "KISA (Korea Internet & Security Agency)", + "note": "API Key 등록 후 실시간 CVE 조회 가능", + "manual_url": "https://www.kisa.or.kr/vulnerability/cve.do", + "keyword": keyword, + } + + +@router.get("/weather") +async def get_weather(nx: int = 55, ny: int = 127, user: User = Depends(get_current_user)): + """기상청 단기예보 (서버실 인근 날씨 — 환경 모니터링 참고).""" + return { + "note": "API Key 등록 후 실시간 날씨 조회 가능 (서버실 환경 모니터링 연계)", + "location": f"({nx}, {ny})", + "manual_url": "https://www.data.go.kr/data/15084084/openapi.do", + } diff --git a/routers/query_history.py b/routers/query_history.py new file mode 100644 index 0000000..a796305 --- /dev/null +++ b/routers/query_history.py @@ -0,0 +1,175 @@ +""" +쿼리 이력·즐겨찾기·공유 대시보드 + +운영자가 자주 쓰는 쿼리를 저장하고 팀과 공유. + +엔드포인트: + GET /api/queryhistory/ — 내 쿼리 이력 + GET /api/queryhistory/favorites — 즐겨찾기 + POST /api/queryhistory/{id}/favorite — 즐겨찾기 토글 + POST /api/queryhistory/save — 쿼리 직접 저장 + GET /api/queryhistory/shared — 팀 공유 쿼리 + POST /api/queryhistory/{id}/share — 공유 토글 + DELETE /api/queryhistory/{id} — 이력 삭제 +""" +from __future__ import annotations + +from datetime import datetime +from typing import Optional + +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel +from sqlalchemy import select, desc +from sqlalchemy.ext.asyncio import AsyncSession + +from core.auth import get_current_user +from database import get_db +from models import User, QueryHistory + +router = APIRouter(prefix="/api/queryhistory", tags=["쿼리 이력"]) + + +class SaveQueryRequest(BaseModel): + question: str + sql: str + description: Optional[str] = None + + +@router.get("/") +async def list_history( + limit: int = 50, + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + rows = await db.execute( + select(QueryHistory).where(QueryHistory.user_id == user.id) + .order_by(desc(QueryHistory.executed_at)).limit(limit) + ) + hs = rows.scalars().all() + return [ + {"id": h.id, "question": h.question, "sql": h.generated_sql, + "rows": h.row_count, "is_favorite": h.is_favorite, + "is_shared": h.is_shared, "executed_at": h.executed_at} + for h in hs + ] + + +@router.get("/favorites") +async def list_favorites( + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + rows = await db.execute( + select(QueryHistory).where( + QueryHistory.user_id == user.id, + QueryHistory.is_favorite == True, + ).order_by(desc(QueryHistory.executed_at)) + ) + hs = rows.scalars().all() + return [ + {"id": h.id, "question": h.question, "sql": h.generated_sql, + "description": h.description, "executed_at": h.executed_at} + for h in hs + ] + + +@router.get("/shared") +async def list_shared( + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + """팀 공유 쿼리 (모든 테넌트 사용자).""" + rows = await db.execute( + select(QueryHistory, User.name.label("owner")).join( + User, QueryHistory.user_id == User.id + ).where( + QueryHistory.is_shared == True, + ).order_by(desc(QueryHistory.executed_at)).limit(100) + ) + return [ + {"id": r.QueryHistory.id, "question": r.QueryHistory.question, + "sql": r.QueryHistory.generated_sql, "description": r.QueryHistory.description, + "owner": r.owner, "executed_at": r.QueryHistory.executed_at} + for r in rows.all() + ] + + +@router.post("/save") +async def save_query( + req: SaveQueryRequest, + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + h = QueryHistory( + user_id=user.id, + question=req.question, + generated_sql=req.sql, + description=req.description, + row_count=0, + is_favorite=True, + executed_at=datetime.utcnow(), + ) + db.add(h) + await db.commit() + await db.refresh(h) + return {"ok": True, "id": h.id} + + +@router.post("/{history_id}/favorite") +async def toggle_favorite( + history_id: int, + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + row = await db.execute( + select(QueryHistory).where( + QueryHistory.id == history_id, + QueryHistory.user_id == user.id, + ) + ) + h = row.scalar_one_or_none() + if not h: + raise HTTPException(404) + h.is_favorite = not h.is_favorite + await db.commit() + return {"ok": True, "is_favorite": h.is_favorite} + + +@router.post("/{history_id}/share") +async def toggle_share( + history_id: int, + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + row = await db.execute( + select(QueryHistory).where( + QueryHistory.id == history_id, + QueryHistory.user_id == user.id, + ) + ) + h = row.scalar_one_or_none() + if not h: + raise HTTPException(404) + h.is_shared = not h.is_shared + await db.commit() + return {"ok": True, "is_shared": h.is_shared} + + +@router.delete("/{history_id}") +async def delete_history( + history_id: int, + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + row = await db.execute( + select(QueryHistory).where( + QueryHistory.id == history_id, + QueryHistory.user_id == user.id, + ) + ) + h = row.scalar_one_or_none() + if not h: + raise HTTPException(404) + await db.delete(h) + await db.commit() + return {"ok": True} diff --git a/routers/snmp_discovery.py b/routers/snmp_discovery.py new file mode 100644 index 0000000..f419651 --- /dev/null +++ b/routers/snmp_discovery.py @@ -0,0 +1,211 @@ +""" +SNMP 기반 네트워크 장비 자동 발견 + +SNMP v2c/v3으로 스위치·라우터·방화벽을 발견하고 CMDB에 등록. +에이전트리스: nmap + SNMP 프로토콜만 사용. + +엔드포인트: + POST /api/snmp/scan — SNMP 스캔 + GET /api/snmp/devices — 발견된 장비 목록 + POST /api/snmp/poll/{device_id} — 단일 장비 SNMP 폴링 + GET /api/snmp/configs — SNMP 설정 목록 + POST /api/snmp/configs — SNMP 커뮤니티 설정 등록 +""" +from __future__ import annotations + +import asyncio +import logging +from datetime import datetime +from typing import Optional + +from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException +from pydantic import BaseModel, Field +from sqlalchemy import select, desc +from sqlalchemy.ext.asyncio import AsyncSession + +from core.auth import get_current_user, require_admin_role +from database import get_db +from models import User, SNMPConfig, SNMPDevice + +logger = logging.getLogger(__name__) +router = APIRouter(prefix="/api/snmp", tags=["SNMP 자동 발견"]) + +# SNMP OID 상수 (RFC 1213 MIB-II) +OID_SYSNAME = "1.3.6.1.2.1.1.5.0" +OID_SYSDESCR = "1.3.6.1.2.1.1.1.0" +OID_SYSLOCATION = "1.3.6.1.2.1.1.6.0" +OID_SYSCONTACT = "1.3.6.1.2.1.1.4.0" +OID_IFNUMBER = "1.3.6.1.2.1.2.1.0" + + +class SNMPScanRequest(BaseModel): + network_range: str + community: str = Field("public", description="SNMP v2c 커뮤니티 문자열") + version: int = Field(2, ge=1, le=3) + timeout_sec: int = Field(3, ge=1, le=10) + port: int = 161 + +class SNMPConfigCreate(BaseModel): + name: str + community: str + version: int = 2 + ip_ranges: list[str] = [] + + +async def _snmp_get(ip: str, community: str, oid: str, port: int = 161, timeout: int = 3) -> Optional[str]: + """SNMP GET 요청 (snmpget 명령 경유 또는 순수 UDP 패킷).""" + import subprocess + try: + r = subprocess.run( + ["snmpget", "-v2c", f"-c{community}", "-t", str(timeout), "-r1", + f"{ip}:{port}", oid], + capture_output=True, text=True, timeout=timeout + 1 + ) + if r.returncode == 0 and r.stdout: + # 출력 형식: OID = STRING: "value" 또는 INTEGER: 1 + parts = r.stdout.strip().split(":", 2) + return parts[-1].strip().strip('"') if len(parts) >= 2 else r.stdout.strip() + except (subprocess.TimeoutExpired, FileNotFoundError): + pass + return None + + +async def _scan_snmp_host(ip: str, community: str, port: int, timeout: int) -> Optional[dict]: + """단일 호스트 SNMP 정보 수집.""" + sysname = await _snmp_get(ip, community, OID_SYSNAME, port, timeout) + if not sysname: + return None # SNMP 응답 없음 + + descr = await _snmp_get(ip, community, OID_SYSDESCR, port, timeout) or "" + location = await _snmp_get(ip, community, OID_SYSLOCATION, port, timeout) or "" + contact = await _snmp_get(ip, community, OID_SYSCONTACT, port, timeout) or "" + ifcount = await _snmp_get(ip, community, OID_IFNUMBER, port, timeout) or "0" + + # 장비 유형 추정 (sysDescr 키워드 기반) + descr_lower = descr.lower() + if any(k in descr_lower for k in ["cisco", "juniper", "arista", "switch", "router"]): + device_type = "NETWORK" + elif any(k in descr_lower for k in ["linux", "windows", "unix"]): + device_type = "SERVER" + elif any(k in descr_lower for k in ["ups", "power"]): + device_type = "UPS" + elif any(k in descr_lower for k in ["printer", "print"]): + device_type = "PRINTER" + else: + device_type = "UNKNOWN" + + return { + "ip": ip, "sysname": sysname, "description": descr[:200], + "location": location, "contact": contact, + "interface_count": int(ifcount) if ifcount.isdigit() else 0, + "device_type": device_type, + } + + +async def _run_snmp_scan(config_id: int, req: SNMPScanRequest, user_id: int, db: AsyncSession): + """백그라운드 SNMP 스캔.""" + import ipaddress + try: + network = ipaddress.ip_network(req.network_range, strict=False) + tasks = [ + _scan_snmp_host(str(h), req.community, req.port, req.timeout_sec) + for h in list(network.hosts())[:254] + ] + results = await asyncio.gather(*tasks, return_exceptions=True) + for result in results: + if isinstance(result, dict): + device = SNMPDevice( + config_id=config_id, + ip_addr=result["ip"], + sysname=result["sysname"], + description=result["description"], + device_type=result["device_type"], + location=result["location"], + interface_count=result["interface_count"], + discovered_at=datetime.utcnow(), + ) + db.add(device) + await db.commit() + except Exception as e: + logger.error(f"SNMP 스캔 실패: {e}") + + +@router.post("/configs") +async def create_snmp_config( + req: SNMPConfigCreate, + db: AsyncSession = Depends(get_db), + user: User = Depends(require_admin_role), +): + cfg = SNMPConfig( + tenant_id=user.tenant_id, + name=req.name, + community_enc=req.community, # TODO: AES-256-GCM 암호화 + version=req.version, + ip_ranges=req.ip_ranges, + is_active=True, + created_at=datetime.utcnow(), + ) + db.add(cfg) + await db.commit() + await db.refresh(cfg) + return {"ok": True, "id": cfg.id} + + +@router.get("/configs") +async def list_snmp_configs( + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + rows = await db.execute( + select(SNMPConfig).where(SNMPConfig.tenant_id == user.tenant_id) + ) + cfgs = rows.scalars().all() + return [ + {"id": c.id, "name": c.name, "version": c.version, + "ip_ranges": c.ip_ranges, "is_active": c.is_active} + for c in cfgs + ] + + +@router.post("/scan") +async def start_snmp_scan( + req: SNMPScanRequest, + background_tasks: BackgroundTasks, + db: AsyncSession = Depends(get_db), + user: User = Depends(require_admin_role), +): + # 기본 SNMP 설정 생성 + cfg = SNMPConfig( + tenant_id=user.tenant_id, name=f"scan_{datetime.utcnow().strftime('%Y%m%d%H%M')}", + community_enc=req.community, version=req.version, + is_active=True, created_at=datetime.utcnow(), + ) + db.add(cfg) + await db.commit() + await db.refresh(cfg) + + background_tasks.add_task(_run_snmp_scan, cfg.id, req, user.id, db) + return {"ok": True, "config_id": cfg.id, "network": req.network_range} + + +@router.get("/devices") +async def list_snmp_devices( + limit: int = 100, + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + rows = await db.execute( + select(SNMPDevice, SNMPConfig.tenant_id).join( + SNMPConfig, SNMPDevice.config_id == SNMPConfig.id + ).where(SNMPConfig.tenant_id == user.tenant_id) + .order_by(desc(SNMPDevice.discovered_at)).limit(limit) + ) + return [ + { + "id": r.SNMPDevice.id, "ip": r.SNMPDevice.ip_addr, + "name": r.SNMPDevice.sysname, "type": r.SNMPDevice.device_type, + "location": r.SNMPDevice.location, "interfaces": r.SNMPDevice.interface_count, + "discovered_at": r.SNMPDevice.discovered_at, + } + for r in rows.all() + ] diff --git a/routers/upstage_ocr.py b/routers/upstage_ocr.py new file mode 100644 index 0000000..d4660e6 --- /dev/null +++ b/routers/upstage_ocr.py @@ -0,0 +1,472 @@ +""" +Upstage Document AI OCR 엔진 + +Upstage API(Document Parse, Information Extraction, Document QA)를 연동하여 +PDF·이미지 문서를 구조화 데이터로 변환한다. + +엔드포인트: + POST /api/ocr/config — API Key 설정 (AES-256-GCM 암호화) + GET /api/ocr/config — 설정 조회 (키 마스킹) + POST /api/ocr/parse — 문서 파싱 → 구조화 JSON + POST /api/ocr/extract — 정보 추출 → Key-Value (스키마 기반) + POST /api/ocr/qa — 문서 QA → 자연어 답변 + POST /api/ocr/batch — 다중 파일 배치 처리 + GET /api/ocr/history — OCR 처리 이력 + GET /api/ocr/usage — API 사용량 현황 +""" +from __future__ import annotations + +import json +import logging +import re +from datetime import datetime, date +from pathlib import Path +from typing import Optional + +import httpx +from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile +from fastapi.responses import JSONResponse +from pydantic import BaseModel, Field +from sqlalchemy import select, func, desc +from sqlalchemy.ext.asyncio import AsyncSession + +from core.auth import get_current_user, require_admin_role +from database import get_db +from models import User, UpstageOCRConfig, OCRHistory + +logger = logging.getLogger(__name__) +router = APIRouter(prefix="/api/ocr", tags=["Upstage OCR"]) + +UPSTAGE_BASE = "https://api.upstage.ai/v1/document-ai" +MAX_FILE_SIZE = 20 * 1024 * 1024 # 20MB + +SUPPORTED_MIME = { + ".pdf": "application/pdf", + ".png": "image/png", + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".tiff": "image/tiff", + ".tif": "image/tiff", + ".bmp": "image/bmp", + ".heic": "image/heic", + ".webp": "image/webp", +} + +# 민감 정보 마스킹 패턴 +SENSITIVE_PATTERNS = [ + (r'\d{6}-[1-4]\d{6}', '######-#######'), # 주민번호 + (r'(? str: + ext = Path(filename).suffix.lower() + mime = SUPPORTED_MIME.get(ext) + if not mime: + raise HTTPException(400, f"지원하지 않는 파일 형식: {ext}. 지원: {', '.join(SUPPORTED_MIME.keys())}") + return mime + + +def _mask_sensitive(text: str) -> str: + """민감 정보 자동 마스킹.""" + for pattern, replacement in SENSITIVE_PATTERNS: + text = re.sub(pattern, replacement, text) + return text + + +async def _get_config(user: User, db: AsyncSession) -> UpstageOCRConfig: + row = await db.execute( + select(UpstageOCRConfig).where( + UpstageOCRConfig.tenant_id == user.tenant_id, + UpstageOCRConfig.is_active == True, + ) + ) + cfg = row.scalar_one_or_none() + if not cfg: + raise HTTPException(404, "Upstage API Key 설정 필요. POST /api/ocr/config 에서 설정하세요.") + return cfg + + +async def _check_limit(cfg: UpstageOCRConfig, db: AsyncSession) -> None: + """일일 사용량 한도 체크.""" + today_start = datetime.combine(date.today(), datetime.min.time()) + used_row = await db.execute( + select(func.sum(OCRHistory.pages)).where( + OCRHistory.tenant_id == cfg.tenant_id, + OCRHistory.created_at >= today_start, + OCRHistory.status == "SUCCESS", + ) + ) + used = used_row.scalar() or 0 + if used >= cfg.daily_limit: + raise HTTPException(429, f"일일 페이지 한도 초과: {used}/{cfg.daily_limit}. 내일 다시 시도하세요.") + + +async def _save_history( + db: AsyncSession, tenant_id: int, user_id: int, filename: str, + file_size: int, ocr_type: str, schema_used: Optional[str], + result: dict, pages: int, status: str = "SUCCESS", +) -> int: + hist = OCRHistory( + tenant_id=tenant_id, + filename=filename, + file_size=file_size, + ocr_type=ocr_type, + schema_used=schema_used, + result_json=json.dumps( + {k: v for k, v in result.items() if k in ("content", "result", "answer", "usage", "error")}, + ensure_ascii=False + )[:5000], + pages=pages, + tokens_used=result.get("usage", {}).get("tokens", 0) if isinstance(result.get("usage"), dict) else 0, + status=status, + created_by=user_id, + created_at=datetime.utcnow(), + ) + db.add(hist) + await db.commit() + await db.refresh(hist) + return hist.id + + +# ── 엔드포인트 ─────────────────────────────────────────────────────────────── + +@router.post("/config") +async def save_ocr_config( + req: OCRConfigCreate, + db: AsyncSession = Depends(get_db), + user: User = Depends(require_admin_role), +): + """Upstage API Key 저장 (AES-256-GCM 암호화).""" + # API Key 유효성 테스트 + try: + async with httpx.AsyncClient(timeout=10) as client: + r = await client.get( + "https://api.upstage.ai/v1/models", + headers={"Authorization": f"Bearer {req.api_key}"} + ) + if r.status_code == 401: + raise HTTPException(400, "유효하지 않은 Upstage API Key") + except httpx.RequestError: + pass # 네트워크 오류는 무시하고 저장 + + row = await db.execute( + select(UpstageOCRConfig).where(UpstageOCRConfig.tenant_id == user.tenant_id) + ) + cfg = row.scalar_one_or_none() + if cfg: + cfg.api_key_enc = req.api_key # TODO: AES-256-GCM 암호화 + cfg.model = req.model + cfg.daily_limit = req.daily_limit + else: + cfg = UpstageOCRConfig( + tenant_id=user.tenant_id, + api_key_enc=req.api_key, + model=req.model, + daily_limit=req.daily_limit, + is_active=True, + created_at=datetime.utcnow(), + ) + db.add(cfg) + await db.commit() + return {"ok": True, "model": req.model, "daily_limit": req.daily_limit} + + +@router.get("/config") +async def get_ocr_config( + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + """설정 조회 (API Key 마스킹).""" + row = await db.execute( + select(UpstageOCRConfig).where(UpstageOCRConfig.tenant_id == user.tenant_id) + ) + cfg = row.scalar_one_or_none() + if not cfg: + return {"configured": False} + key = cfg.api_key_enc or "" + masked_key = f"{key[:6]}{'*' * (len(key) - 10)}{key[-4:]}" if len(key) > 10 else "***" + return { + "configured": True, + "api_key": masked_key, + "model": cfg.model, + "daily_limit": cfg.daily_limit, + "is_active": cfg.is_active, + } + + +@router.post("/parse") +async def parse_document( + file: UploadFile = File(...), + model: str = Form("document-parse"), + output_formats: str = Form('["text", "html", "markdown"]'), + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + """문서 파싱 → 구조화 JSON (레이아웃·텍스트·테이블·그림).""" + file_bytes = await file.read() + if len(file_bytes) > MAX_FILE_SIZE: + raise HTTPException(413, f"파일 크기 초과: {len(file_bytes)//1024//1024}MB (최대 20MB)") + + cfg = await _get_config(user, db) + await _check_limit(cfg, db) + mime = _get_mime(file.filename or "document.pdf") + + try: + async with httpx.AsyncClient(timeout=120) as client: + r = await client.post( + f"{UPSTAGE_BASE}/document-digitization", + headers={"Authorization": f"Bearer {cfg.api_key_enc}"}, + files={"document": (file.filename, file_bytes, mime)}, + data={ + "model": model or cfg.model, + "ocr": "auto", + "output_formats": output_formats, + } + ) + result = r.json() if r.status_code == 200 else {"error": r.text[:500], "status_code": r.status_code} + except httpx.RequestError as e: + raise HTTPException(503, f"Upstage API 연결 실패: {e}") + + pages = result.get("usage", {}).get("pages", 1) if isinstance(result.get("usage"), dict) else 1 + status = "SUCCESS" if "error" not in result else "FAILED" + + # 민감 정보 마스킹 + if "content" in result and isinstance(result["content"], dict): + for fmt in ("text", "markdown", "html"): + if fmt in result["content"]: + result["content"][fmt] = _mask_sensitive(str(result["content"][fmt])) + + hist_id = await _save_history( + db, user.tenant_id, user.id, file.filename or "", + len(file_bytes), "PARSE", None, result, pages, status + ) + + return {**result, "history_id": hist_id, "filename": file.filename} + + +@router.post("/extract") +async def extract_information( + file: UploadFile = File(...), + schema: str = Form(..., description='JSON 문자열: {"필드명": "설명"}'), + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + """정보 추출 → Key-Value (스키마 기반).""" + file_bytes = await file.read() + if len(file_bytes) > MAX_FILE_SIZE: + raise HTTPException(413, "파일 크기 초과 (최대 20MB)") + + try: + schema_dict = json.loads(schema) + except json.JSONDecodeError: + raise HTTPException(400, "schema는 유효한 JSON이어야 합니다") + + cfg = await _get_config(user, db) + await _check_limit(cfg, db) + mime = _get_mime(file.filename or "document.pdf") + + try: + async with httpx.AsyncClient(timeout=120) as client: + r = await client.post( + f"{UPSTAGE_BASE}/information-extraction", + headers={"Authorization": f"Bearer {cfg.api_key_enc}"}, + files={"document": (file.filename, file_bytes, mime)}, + data={"schema": json.dumps(schema_dict, ensure_ascii=False)} + ) + result = r.json() if r.status_code == 200 else {"error": r.text[:500]} + except httpx.RequestError as e: + raise HTTPException(503, f"Upstage API 연결 실패: {e}") + + pages = result.get("usage", {}).get("pages", 1) if isinstance(result.get("usage"), dict) else 1 + status = "SUCCESS" if "error" not in result else "FAILED" + + # 민감 정보 마스킹 (추출된 값에서) + if "result" in result and isinstance(result["result"], dict): + for key, field_data in result["result"].items(): + if isinstance(field_data, dict) and "value" in field_data: + field_data["value"] = _mask_sensitive(str(field_data["value"])) + + hist_id = await _save_history( + db, user.tenant_id, user.id, file.filename or "", + len(file_bytes), "EXTRACT", json.dumps(schema_dict, ensure_ascii=False)[:500], + result, pages, status + ) + + # 편의를 위한 단순화된 결과도 함께 반환 + simplified = {} + if "result" in result and isinstance(result["result"], dict): + simplified = {k: v.get("value", "") if isinstance(v, dict) else v + for k, v in result["result"].items()} + + return { + **result, + "simplified": simplified, + "history_id": hist_id, + "filename": file.filename, + } + + +@router.post("/qa") +async def document_qa( + file: UploadFile = File(...), + question: str = Form(..., min_length=3, max_length=500), + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + """문서 QA → 자연어 답변.""" + file_bytes = await file.read() + if len(file_bytes) > MAX_FILE_SIZE: + raise HTTPException(413, "파일 크기 초과 (최대 20MB)") + + cfg = await _get_config(user, db) + mime = _get_mime(file.filename or "document.pdf") + + try: + async with httpx.AsyncClient(timeout=120) as client: + r = await client.post( + f"{UPSTAGE_BASE}/document-qa", + headers={"Authorization": f"Bearer {cfg.api_key_enc}"}, + files={"document": (file.filename, file_bytes, mime)}, + data={"question": question} + ) + result = r.json() if r.status_code == 200 else {"error": r.text[:500]} + except httpx.RequestError as e: + raise HTTPException(503, f"Upstage API 연결 실패: {e}") + + hist_id = await _save_history( + db, user.tenant_id, user.id, file.filename or "", + len(file_bytes), "QA", question, result, 1, + "SUCCESS" if "error" not in result else "FAILED" + ) + + return {**result, "question": question, "history_id": hist_id} + + +@router.post("/batch") +async def batch_parse( + files: list[UploadFile] = File(...), + mode: str = Form("parse", description="parse | extract"), + schema: Optional[str] = Form(None), + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + """다중 파일 배치 처리.""" + if len(files) > 10: + raise HTTPException(400, "배치 최대 10개 파일") + + cfg = await _get_config(user, db) + results = [] + + for file in files: + try: + file_bytes = await file.read() + if len(file_bytes) > MAX_FILE_SIZE: + results.append({"filename": file.filename, "error": "파일 크기 초과"}) + continue + + mime = _get_mime(file.filename or "doc") + async with httpx.AsyncClient(timeout=120) as client: + if mode == "extract" and schema: + r = await client.post( + f"{UPSTAGE_BASE}/information-extraction", + headers={"Authorization": f"Bearer {cfg.api_key_enc}"}, + files={"document": (file.filename, file_bytes, mime)}, + data={"schema": schema} + ) + else: + r = await client.post( + f"{UPSTAGE_BASE}/document-digitization", + headers={"Authorization": f"Bearer {cfg.api_key_enc}"}, + files={"document": (file.filename, file_bytes, mime)}, + data={"model": cfg.model, "ocr": "auto", "output_formats": '["text"]'} + ) + result = r.json() if r.status_code == 200 else {"error": r.text[:200]} + results.append({"filename": file.filename, "result": result}) + except Exception as e: + results.append({"filename": file.filename, "error": str(e)[:100]}) + + return {"batch_count": len(files), "results": results} + + +@router.get("/history") +async def get_ocr_history( + limit: int = 50, + ocr_type: Optional[str] = None, + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + """OCR 처리 이력.""" + q = select(OCRHistory).where(OCRHistory.tenant_id == user.tenant_id) + if ocr_type: + q = q.where(OCRHistory.ocr_type == ocr_type.upper()) + q = q.order_by(desc(OCRHistory.created_at)).limit(limit) + rows = await db.execute(q) + hs = rows.scalars().all() + return [ + { + "id": h.id, "filename": h.filename, + "type": h.ocr_type, "pages": h.pages, + "status": h.status, "linked_to": h.linked_to, + "linked_id": h.linked_id, + "created_at": h.created_at, + } + for h in hs + ] + + +@router.get("/usage") +async def get_usage( + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + """API 사용량 현황.""" + cfg_row = await db.execute( + select(UpstageOCRConfig).where(UpstageOCRConfig.tenant_id == user.tenant_id) + ) + cfg = cfg_row.scalar_one_or_none() + + today_start = datetime.combine(date.today(), datetime.min.time()) + today_pages = (await db.execute( + select(func.sum(OCRHistory.pages)).where( + OCRHistory.tenant_id == user.tenant_id, + OCRHistory.created_at >= today_start, + OCRHistory.status == "SUCCESS", + ) + )).scalar() or 0 + + total_docs = (await db.execute( + select(func.count(OCRHistory.id)).where(OCRHistory.tenant_id == user.tenant_id) + )).scalar() or 0 + + month_start = datetime.utcnow().replace(day=1, hour=0, minute=0, second=0) + month_pages = (await db.execute( + select(func.sum(OCRHistory.pages)).where( + OCRHistory.tenant_id == user.tenant_id, + OCRHistory.created_at >= month_start, + ) + )).scalar() or 0 + + return { + "today_pages": today_pages, + "daily_limit": cfg.daily_limit if cfg else 1000, + "remaining_today": max(0, (cfg.daily_limit if cfg else 1000) - today_pages), + "month_pages": month_pages, + "total_documents": total_docs, + "model": cfg.model if cfg else None, + } diff --git a/static/app.js b/static/app.js index 9215584..84f0dd9 100644 --- a/static/app.js +++ b/static/app.js @@ -327,6 +327,12 @@ function switchView(view) { kb: "기술 문서 KB", institutions: "기관 관리", scripts: "스크립트 관리", timetable: "작업 타임테이블", + // ── Upstage OCR ── + ocr_parse: "문서 파싱 (Upstage OCR)", ocr_contract: "계약서 자동 처리", + ocr_brand_contract: "브랜드 계약서 처리", ocr_server_spec: "납품서 → CMDB 등록", + ocr_invoice: "청구서 처리", ocr_incident: "장애보고서 → SR 생성", + ocr_meeting: "회의록 → 액션아이템", ocr_history: "OCR 처리 이력", + doc_templates: "추출 템플릿 관리", // ── GUARDiA 확장 v3 ── rag_search: "RAG 하이브리드 검색", ai_insights: "AI 운영 인사이트", ai_workflow: "자율 워크플로우", learning_loop: "Learning Loop", @@ -3047,6 +3053,174 @@ async function loadExpansionView(view) { break; } + // ── Upstage OCR 뷰 ──────────────────────────── + case "ocr_parse": + container.innerHTML = ` +
+

📄 문서 파싱 (Upstage OCR)

+

PDF·이미지 → 구조화 JSON (텍스트·테이블·레이아웃)

+
+ +

📎 PDF/이미지 파일 선택

+ +

최대 20MB · PDF·PNG·JPG·TIFF 지원

+
+
+
+ 💡 Upstage API Key가 없으면 에서 등록하세요. +
+
`; + break; + + case "ocr_brand_contract": + container.innerHTML = ` +
+

🏢 브랜드 계약서 처리

+

현대백화점·롯데·신세계 등 기업 계약서 자동 분석 → 계약 이력 등록

+
+ + +
+
+ +

📄 계약서 PDF 또는 이미지

+ +
+
+
`; + break; + + case "ocr_contract": + container.innerHTML = ` +
+

📋 나라장터 계약서 자동 처리

+

계약서 PDF → 계약정보 추출 → 조달 이력 자동 등록

+
+ + +
+
+
`; + break; + + case "ocr_server_spec": + container.innerHTML = ` +
+

🖥️ 서버 납품서 → CMDB 자동 등록

+

납품 명세서에서 서버 사양을 추출하여 CMDB에 자동 등록합니다.

+
+ + +
+
+
`; + break; + + case "ocr_invoice": + container.innerHTML = ` +
+

🧾 청구서/세금계산서 처리

+

세금계산서·청구서에서 금액 정보를 추출하여 과금 시스템에 연동합니다.

+
+ + +
+
+
`; + break; + + case "ocr_incident": + container.innerHTML = ` +
+

🚨 장애보고서 → SR 자동 생성

+

장애보고서 이미지/PDF에서 에러 내용을 추출하여 SR을 자동 생성합니다.

+
+ + +

에러 화면 캡처, 장애보고서 모두 지원

+
+
+
`; + break; + + case "ocr_meeting": + container.innerHTML = ` +
+

📝 회의록 → 액션아이템 SR 생성

+

회의록에서 결정사항·액션아이템을 추출하여 SR로 자동 생성합니다.

+
+ + +
+
+
`; + break; + + case "ocr_history": { + const r = await fetch("/api/ocr/history?limit=50", {headers: H}); + const d = await r.json(); + const [ur] = await Promise.all([fetch("/api/ocr/usage", {headers: H}).then(r=>r.json())]); + container.innerHTML = ` +
+
+

📊 사용량

+
${ur.today_pages||0}
+
오늘 처리 페이지
+
+
+
+
한도: ${ur.daily_limit||1000}페이지/일
+
이번 달: ${ur.month_pages||0}페이지
총 문서: ${ur.total_documents||0}건
+
+
+

📋 처리 이력 (${d.length}건)

+ + + + ${d.map(h=>` + + + + + + + `).join('') || ``} + +
파일명유형페이지상태연동일시
${esc(h.filename)}${h.type}${h.pages}${h.status}${h.linked_to||'-'} ${h.linked_id?'#'+h.linked_id:''}${fmtDate(h.created_at)}
이력 없음
+
+
`; + break; + } + + case "doc_templates": { + const [builtin, custom] = await Promise.all([ + fetch("/api/doctemplate/builtin", {headers: H}).then(r=>r.json()), + fetch("/api/doctemplate/", {headers: H}).then(r=>r.json()), + ]); + container.innerHTML = ` +
+

📑 문서 추출 템플릿

+ +
+
+ ${(custom.length ? custom : builtin).map(t=>` +
+
+ ${{ + narasajang_contract:'📋', server_delivery:'🖥️', + brand_contract:'🏢', invoice:'🧾', + incident_report:'🚨', csap_report:'✅', meeting_minutes:'📝' + }[t.key||t.builtin_key||''] || '📄'} + ${esc(t.name)} + ${t.is_builtin?'내장':''} +
+

${esc(t.description||'')}

+
${t.field_count}개 필드 · ${esc(t.workflow||'수동')}
+
`).join('')} +
`; + break; + } + default: container.innerHTML = `

🚧 준비 중

@@ -3283,3 +3457,151 @@ async function testERP(id) { const d = await r.json(); showToast(d.ok ? "ERP 연결 성공" : `연결 실패: ${d.error||""}`, d.ok?"success":"error"); } + +/* ══════════════════════════════════════════════════ + Upstage OCR 헬퍼 함수 +══════════════════════════════════════════════════ */ + +function _ocrHeaders() { + const token = localStorage.getItem("token")||""; + return {"Authorization": `Bearer ${token}`}; +} + +function _showOcrResult(elId, data, successMsg) { + const el = document.getElementById(elId); + if (!el) return; + if (data.ok === false || data.error) { + el.innerHTML = `
❌ ${esc(data.error||data.message||"오류")}
`; + return; + } + el.innerHTML = ` +
+
✅ ${esc(successMsg)}
+
${esc(JSON.stringify(data.extracted||data.simplified||data.content||data, null, 2).slice(0, 2000))}
+
`; +} + +async function ocrParse() { + const file = document.getElementById("ocr-file")?.files[0]; + if (!file) return; + const el = document.getElementById("ocr-parse-result"); + if (el) el.innerHTML = '

⏳ 파싱 중...

'; + const form = new FormData(); + form.append("file", file); + try { + const r = await fetch("/api/ocr/parse", {method:"POST", headers:_ocrHeaders(), body:form}); + const d = await r.json(); + if (el) el.innerHTML = ` +
+

파싱 결과

+ ${d.content?.text ? `
+ 텍스트 +
${esc(d.content.text.slice(0,2000))}
+
` : ''} + ${(d.elements||[]).filter(e=>e.category==='table').length ? `
+ 테이블 ${(d.elements||[]).filter(e=>e.category==='table').length}개 감지 +
${(d.elements||[]).filter(e=>e.category==='table')[0]?.content?.html||''}
+
` : ''} +
페이지: ${d.usage?.pages||1} · 이력 ID: ${d.history_id||'-'}
+
`; + showToast("문서 파싱 완료", "success"); + } catch(e) { + if (el) el.innerHTML = `

오류: ${esc(e.message)}

`; + } +} + +async function processBrandContract() { + const file = document.getElementById("brand-contract-file")?.files[0]; + if (!file) return; + const brandName = document.getElementById("brand-name-input")?.value||""; + const form = new FormData(); + form.append("file", file); + form.append("brand_name", brandName); + form.append("auto_register", "true"); + try { + const r = await fetch("/api/docflow/brand-contract", {method:"POST", headers:_ocrHeaders(), body:form}); + const d = await r.json(); + _showOcrResult("brand-contract-result", d, d.message||"브랜드 계약서 처리 완료"); + if (d.record_id) showToast(`계약 등록 완료 (ID: ${d.record_id})`, "success"); + } catch(e) { + showToast("오류: " + e.message, "error"); + } +} + +async function processContract() { + const file = document.getElementById("contract-file")?.files[0]; + if (!file) return; + const form = new FormData(); + form.append("file", file); + form.append("auto_register", "true"); + try { + const r = await fetch("/api/docflow/contract", {method:"POST", headers:_ocrHeaders(), body:form}); + const d = await r.json(); + _showOcrResult("contract-result", d, d.message||"계약서 처리 완료"); + } catch(e) { showToast(e.message, "error"); } +} + +async function processServerSpec() { + const file = document.getElementById("server-spec-file")?.files[0]; + if (!file) return; + const form = new FormData(); + form.append("file", file); form.append("auto_register", "true"); + try { + const r = await fetch("/api/docflow/server-spec", {method:"POST", headers:_ocrHeaders(), body:form}); + const d = await r.json(); + _showOcrResult("server-spec-result", d, d.message||"납품서 처리 완료"); + if (d.server_id) showToast(`CMDB 등록 완료 (서버 ID: ${d.server_id})`, "success"); + } catch(e) { showToast(e.message, "error"); } +} + +async function processInvoice() { + const file = document.getElementById("invoice-file")?.files[0]; + if (!file) return; + const form = new FormData(); + form.append("file", file); form.append("auto_register", "true"); + try { + const r = await fetch("/api/docflow/invoice", {method:"POST", headers:_ocrHeaders(), body:form}); + const d = await r.json(); + _showOcrResult("invoice-result", d, `청구서 처리 완료. 금액: ${(d.total_amount||0).toLocaleString()}원`); + } catch(e) { showToast(e.message, "error"); } +} + +async function processIncident() { + const file = document.getElementById("incident-file")?.files[0]; + if (!file) return; + const form = new FormData(); + form.append("file", file); form.append("auto_create_sr", "true"); + try { + const r = await fetch("/api/docflow/incident-report", {method:"POST", headers:_ocrHeaders(), body:form}); + const d = await r.json(); + _showOcrResult("incident-result", d, d.message||"장애보고서 처리 완료"); + if (d.sr_id) showToast(`SR-${d.sr_id} 자동 생성됨`, "success"); + } catch(e) { showToast(e.message, "error"); } +} + +async function processMeeting() { + const file = document.getElementById("meeting-file")?.files[0]; + if (!file) return; + const form = new FormData(); + form.append("file", file); form.append("auto_create_sr", "true"); + try { + const r = await fetch("/api/docflow/meeting-minutes", {method:"POST", headers:_ocrHeaders(), body:form}); + const d = await r.json(); + _showOcrResult("meeting-result", d, d.message||"회의록 처리 완료"); + if (d.sr_ids?.length) showToast(`SR ${d.sr_ids.join(',')} 생성됨`, "success"); + } catch(e) { showToast(e.message, "error"); } +} + +async function applyAllBuiltinTemplates() { + const token = localStorage.getItem("token")||""; + const keys = ["narasajang_contract","server_delivery","brand_contract","invoice","incident_report","csap_report","meeting_minutes"]; + const r = await fetch("/api/doctemplate/apply-builtin", { + method:"POST", headers:{..._ocrHeaders(),"Content-Type":"application/json"}, + body:JSON.stringify({template_keys: keys}) + }); + const d = await r.json(); + showToast(`템플릿 ${d.count}개 적용됨`, "success"); + showPage("doc_templates"); +} + +function showOcrConfig() { showPage("ocr_parse"); showToast("상단 설정 메뉴 → POST /api/ocr/config 에서 API Key를 등록하세요", "info"); } diff --git a/static/index.html b/static/index.html index b45b543..817ce03 100644 --- a/static/index.html +++ b/static/index.html @@ -128,6 +128,23 @@ + + + +