sync: update from workspace (latest ITSM/CICD/DR changes)

This commit is contained in:
DESKTOP-TKLFCPR\ython 2026-06-02 18:48:18 +09:00
parent de0cecf286
commit 7eece4e49e
27 changed files with 5533 additions and 1 deletions

40
main.py
View File

@ -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")

338
models.py
View File

@ -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())

176
routers/auto_remediation.py Normal file
View File

@ -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]}

291
routers/autodiscovery.py Normal file
View File

@ -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}

140
routers/aws_connector.py Normal file
View File

@ -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에서만 조회 가능 — 별도 설정 필요"}

143
routers/cloud_migration.py Normal file
View File

@ -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,
}

207
routers/config_inventory.py Normal file
View File

@ -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

94
routers/cost_optimizer.py Normal file
View File

@ -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(),
}

231
routers/dependency_map.py Normal file
View File

@ -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}

341
routers/doc_template.py Normal file
View File

@ -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}

610
routers/doc_workflow.py Normal file
View File

@ -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,
}

276
routers/drift_detection.py Normal file
View File

@ -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"}

127
routers/e_procurement.py Normal file
View File

@ -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(),
}

211
routers/golden_config.py Normal file
View File

@ -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}

111
routers/isp_support.py Normal file
View File

@ -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 운영 데이터 기반 자동 생성 초안 — 담당자 검토 필요",
}

111
routers/k_cloud.py Normal file
View File

@ -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(),
}

117
routers/multicloud.py Normal file
View File

@ -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(),
}

108
routers/narasajang.py Normal file
View File

@ -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}

124
routers/network_zone.py Normal file
View File

@ -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)}

231
routers/nlquery.py Normal file
View File

@ -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}

208
routers/op_assistant.py Normal file
View File

@ -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}

102
routers/public_api_hub.py Normal file
View File

@ -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",
}

175
routers/query_history.py Normal file
View File

@ -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}

211
routers/snmp_discovery.py Normal file
View File

@ -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()
]

472
routers/upstage_ocr.py Normal file
View File

@ -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'(?<!\d)\d{4}[-\s]\d{4}[-\s]\d{4}[-\s]\d{4}', '****-****-****-****'), # 카드번호
(r'(?<!\w)\d{3}-\d{4}-\d{4}(?!\w)', '***-****-****'), # 전화번호
]
class OCRConfigCreate(BaseModel):
api_key: str = Field(..., min_length=10)
model: str = Field("document-parse", description="document-parse | document-parse-ocr")
daily_limit: int = Field(1000, ge=1, description="일일 페이지 한도")
class ExtractRequest(BaseModel):
schema: dict = Field(..., description="추출 스키마 {필드명: 설명}")
class QARequest(BaseModel):
question: str = Field(..., min_length=3, max_length=500)
def _get_mime(filename: str) -> 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,
}

View File

@ -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 = `
<div class="card" style="padding:24px">
<h3>📄 문서 파싱 (Upstage OCR)</h3>
<p style="color:var(--text-muted);margin-bottom:16px">PDF·이미지 구조화 JSON (텍스트·테이블·레이아웃)</p>
<div style="border:2px dashed var(--border);border-radius:10px;padding:32px;text-align:center;margin-bottom:16px">
<input type="file" id="ocr-file" accept=".pdf,.png,.jpg,.jpeg,.tiff" style="display:none" onchange="ocrParse()">
<p>📎 PDF/이미지 파일 선택</p>
<button class="btn btn-primary btn-sm" onclick="document.getElementById('ocr-file').click()">파일 선택</button>
<p style="font-size:12px;color:var(--text-muted);margin-top:8px">최대 20MB · PDF·PNG·JPG·TIFF 지원</p>
</div>
<div id="ocr-parse-result"></div>
<div style="margin-top:16px;padding:12px;background:var(--bg-tertiary);border-radius:8px;font-size:12px">
💡 <strong>Upstage API Key</strong> <button class="btn btn-sm btn-secondary" onclick="showOcrConfig()"></button> .
</div>
</div>`;
break;
case "ocr_brand_contract":
container.innerHTML = `
<div class="card" style="padding:24px;max-width:680px">
<h3>🏢 브랜드 계약서 처리</h3>
<p style="color:var(--text-muted);margin-bottom:20px">현대백화점·롯데·신세계 기업 계약서 자동 분석 계약 이력 등록</p>
<div class="form-group">
<label>브랜드사명 (선택)</label>
<input class="form-control" id="brand-name-input" placeholder="예: 현대백화점" />
</div>
<div style="border:2px dashed var(--border);border-radius:10px;padding:24px;text-align:center;margin:12px 0">
<input type="file" id="brand-contract-file" accept=".pdf,.png,.jpg" style="display:none" onchange="processBrandContract()">
<p>📄 계약서 PDF 또는 이미지</p>
<button class="btn btn-primary" onclick="document.getElementById('brand-contract-file').click()">계약서 업로드</button>
</div>
<div id="brand-contract-result"></div>
</div>`;
break;
case "ocr_contract":
container.innerHTML = `
<div class="card" style="padding:24px;max-width:680px">
<h3>📋 나라장터 계약서 자동 처리</h3>
<p style="color:var(--text-muted);margin-bottom:20px">계약서 PDF 계약정보 추출 조달 이력 자동 등록</p>
<div style="border:2px dashed var(--border);border-radius:10px;padding:24px;text-align:center;margin:12px 0">
<input type="file" id="contract-file" accept=".pdf,.png,.jpg" style="display:none" onchange="processContract()">
<button class="btn btn-primary" onclick="document.getElementById('contract-file').click()">계약서 업로드</button>
</div>
<div id="contract-result"></div>
</div>`;
break;
case "ocr_server_spec":
container.innerHTML = `
<div class="card" style="padding:24px;max-width:680px">
<h3>🖥 서버 납품서 CMDB 자동 등록</h3>
<p style="color:var(--text-muted);margin-bottom:20px">납품 명세서에서 서버 사양을 추출하여 CMDB에 자동 등록합니다.</p>
<div style="border:2px dashed var(--border);border-radius:10px;padding:24px;text-align:center;margin:12px 0">
<input type="file" id="server-spec-file" accept=".pdf,.png,.jpg" style="display:none" onchange="processServerSpec()">
<button class="btn btn-primary" onclick="document.getElementById('server-spec-file').click()">납품서 업로드</button>
</div>
<div id="server-spec-result"></div>
</div>`;
break;
case "ocr_invoice":
container.innerHTML = `
<div class="card" style="padding:24px;max-width:680px">
<h3>🧾 청구서/세금계산서 처리</h3>
<p style="color:var(--text-muted);margin-bottom:20px">세금계산서·청구서에서 금액 정보를 추출하여 과금 시스템에 연동합니다.</p>
<div style="border:2px dashed var(--border);border-radius:10px;padding:24px;text-align:center;margin:12px 0">
<input type="file" id="invoice-file" accept=".pdf,.png,.jpg" style="display:none" onchange="processInvoice()">
<button class="btn btn-primary" onclick="document.getElementById('invoice-file').click()">청구서 업로드</button>
</div>
<div id="invoice-result"></div>
</div>`;
break;
case "ocr_incident":
container.innerHTML = `
<div class="card" style="padding:24px;max-width:680px">
<h3>🚨 장애보고서 SR 자동 생성</h3>
<p style="color:var(--text-muted);margin-bottom:20px">장애보고서 이미지/PDF에서 에러 내용을 추출하여 SR을 자동 생성합니다.</p>
<div style="border:2px dashed var(--border);border-radius:10px;padding:24px;text-align:center;margin:12px 0">
<input type="file" id="incident-file" accept=".pdf,.png,.jpg,.jpeg" style="display:none" onchange="processIncident()">
<button class="btn btn-primary" onclick="document.getElementById('incident-file').click()">보고서/화면 업로드</button>
<p style="font-size:12px;color:var(--text-muted);margin-top:6px">에러 화면 캡처, 장애보고서 모두 지원</p>
</div>
<div id="incident-result"></div>
</div>`;
break;
case "ocr_meeting":
container.innerHTML = `
<div class="card" style="padding:24px;max-width:680px">
<h3>📝 회의록 액션아이템 SR 생성</h3>
<p style="color:var(--text-muted);margin-bottom:20px">회의록에서 결정사항·액션아이템을 추출하여 SR로 자동 생성합니다.</p>
<div style="border:2px dashed var(--border);border-radius:10px;padding:24px;text-align:center;margin:12px 0">
<input type="file" id="meeting-file" accept=".pdf,.png,.jpg" style="display:none" onchange="processMeeting()">
<button class="btn btn-primary" onclick="document.getElementById('meeting-file').click()">회의록 업로드</button>
</div>
<div id="meeting-result"></div>
</div>`;
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 = `
<div style="display:grid;grid-template-columns:1fr 3fr;gap:16px">
<div class="card" style="padding:20px">
<h4>📊 사용량</h4>
<div style="font-size:32px;font-weight:700;color:#003366">${ur.today_pages||0}</div>
<div style="font-size:12px;color:var(--text-muted)">오늘 처리 페이지</div>
<div style="margin-top:8px;background:var(--bg-tertiary);border-radius:4px;height:6px">
<div style="width:${Math.min(100,Math.round((ur.today_pages||0)/(ur.daily_limit||1000)*100))}%;background:#003366;height:6px;border-radius:4px"></div>
</div>
<div style="font-size:11px;color:var(--text-muted);margin-top:4px">한도: ${ur.daily_limit||1000}페이지/</div>
<div style="margin-top:12px;font-size:12px">이번 : ${ur.month_pages||0}페이지<br> 문서: ${ur.total_documents||0}</div>
</div>
<div class="card" style="padding:20px">
<h4>📋 처리 이력 (${d.length})</h4>
<table class="table table-sm" style="font-size:12px">
<thead><tr><th>파일명</th><th></th><th></th><th></th><th></th><th></th></tr></thead>
<tbody>
${d.map(h=>`<tr>
<td style="max-width:150px;overflow:hidden;text-overflow:ellipsis">${esc(h.filename)}</td>
<td><span style="background:var(--bg-tertiary);padding:2px 6px;border-radius:4px;font-size:11px">${h.type}</span></td>
<td>${h.pages}</td>
<td style="color:${h.status==='SUCCESS'?'#10B981':'#EF4444'}">${h.status}</td>
<td style="font-size:11px;color:var(--text-muted)">${h.linked_to||'-'} ${h.linked_id?'#'+h.linked_id:''}</td>
<td style="font-size:11px">${fmtDate(h.created_at)}</td>
</tr>`).join('') || `<tr><td colspan="6" style="text-align:center;color:var(--text-muted)"> </td></tr>`}
</tbody>
</table>
</div>
</div>`;
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 = `
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px">
<h3 style="margin:0">📑 문서 추출 템플릿</h3>
<button class="btn btn-primary btn-sm" onclick="applyAllBuiltinTemplates()">📋 내장 템플릿 7 모두 적용</button>
</div>
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(260px,1fr));gap:12px">
${(custom.length ? custom : builtin).map(t=>`
<div class="card" style="padding:16px">
<div style="display:flex;align-items:center;gap:8px;margin-bottom:8px">
<span style="font-size:18px">${{
narasajang_contract:'📋', server_delivery:'🖥️',
brand_contract:'🏢', invoice:'🧾',
incident_report:'🚨', csap_report:'✅', meeting_minutes:'📝'
}[t.key||t.builtin_key||''] || '📄'}</span>
<strong style="font-size:13px">${esc(t.name)}</strong>
${t.is_builtin?'<span style="font-size:10px;background:#003366;color:#fff;padding:1px 6px;border-radius:8px">내장</span>':''}
</div>
<p style="font-size:12px;color:var(--text-muted);margin:0 0 8px">${esc(t.description||'')}</p>
<div style="font-size:11px;color:var(--text-muted)">${t.field_count} 필드 · ${esc(t.workflow||'수동')}</div>
</div>`).join('')}
</div>`;
break;
}
default:
container.innerHTML = `<div class="card" style="padding:40px;text-align:center">
<h4>🚧 준비 </h4>
@ -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 = `<div style="padding:12px;background:#fef2f2;border-radius:8px;color:#991b1b;margin-top:12px">❌ ${esc(data.error||data.message||"오류")} </div>`;
return;
}
el.innerHTML = `
<div style="padding:14px;background:#f0fdf4;border-radius:8px;margin-top:12px">
<div style="color:#166534;font-weight:600;margin-bottom:8px"> ${esc(successMsg)}</div>
<pre style="white-space:pre-wrap;font-size:12px;max-height:300px;overflow-y:auto;background:#fff;padding:10px;border-radius:6px;border:1px solid #e2e8f0">${esc(JSON.stringify(data.extracted||data.simplified||data.content||data, null, 2).slice(0, 2000))}</pre>
</div>`;
}
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 = '<p style="color:var(--text-muted)">⏳ 파싱 중...</p>';
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 = `
<div style="margin-top:16px">
<h4 style="font-size:14px;font-weight:600">파싱 결과</h4>
${d.content?.text ? `<div class="card" style="padding:16px;margin-bottom:12px">
<strong style="font-size:12px">텍스트</strong>
<pre style="white-space:pre-wrap;font-size:12px;max-height:300px;overflow-y:auto">${esc(d.content.text.slice(0,2000))}</pre>
</div>` : ''}
${(d.elements||[]).filter(e=>e.category==='table').length ? `<div class="card" style="padding:16px">
<strong style="font-size:12px">테이블 ${(d.elements||[]).filter(e=>e.category==='table').length} 감지</strong>
<div style="margin-top:8px;font-size:12px;overflow-x:auto">${(d.elements||[]).filter(e=>e.category==='table')[0]?.content?.html||''}</div>
</div>` : ''}
<div style="font-size:11px;color:var(--text-muted);margin-top:8px">페이지: ${d.usage?.pages||1} · 이력 ID: ${d.history_id||'-'}</div>
</div>`;
showToast("문서 파싱 완료", "success");
} catch(e) {
if (el) el.innerHTML = `<p style="color:#EF4444">오류: ${esc(e.message)}</p>`;
}
}
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"); }

View File

@ -128,6 +128,23 @@
<div class="nav-item" data-view="timetable">
<span class="nav-icon">📅</span> 작업 타임테이블
</div>
<!-- ── Upstage OCR ───────────────────────────── -->
<div class="nav-separator"></div>
<div class="nav-group-header" onclick="toggleNavGroup(this)" aria-expanded="false">
<span class="nav-icon">📄</span><span>문서 AI (OCR)</span>
<span class="nav-arrow" aria-hidden="true"></span>
</div>
<div class="nav-group-body" role="group">
<div class="nav-sub-item" data-view="ocr_parse">문서 파싱 (Parse)</div>
<div class="nav-sub-item" data-view="ocr_contract">계약서 자동 처리</div>
<div class="nav-sub-item" data-view="ocr_brand_contract">브랜드 계약서</div>
<div class="nav-sub-item" data-view="ocr_server_spec">납품서 → CMDB</div>
<div class="nav-sub-item" data-view="ocr_invoice">청구서 처리</div>
<div class="nav-sub-item" data-view="ocr_incident">장애보고서 → SR</div>
<div class="nav-sub-item" data-view="ocr_meeting">회의록 → 액션</div>
<div class="nav-sub-item" data-view="ocr_history">OCR 이력</div>
<div class="nav-sub-item" data-view="doc_templates">추출 템플릿</div>
</div>
<!-- ── GUARDiA 확장 v3 ─────────────────────── -->
<div class="nav-separator"></div>