- itsm/ -> workspace/guardia-itsm/ - manager/ -> workspace/guardia-manager/ - app/ -> workspace/guardia-messenger/ - manual/ -> workspace/guardia-docs/ workspace/zioinfo-web/ unchanged. git mv preserves full commit history. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
503 lines
17 KiB
Python
503 lines
17 KiB
Python
"""
|
|
RPA (Robotic Process Automation) 라우터
|
|
- Validation 학습: models.py AST + routers/ 스캔
|
|
- 규칙 영속: rpa_rules.json
|
|
- RPA 작업 등록/수정/삭제/실행 + 크론 스케줄러 연동
|
|
- 실행 이력 조회
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
from typing import Any, Dict, List, Optional
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
|
from pydantic import BaseModel
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from core.auth import get_current_user
|
|
from database import get_db
|
|
from models import User
|
|
from core.rpa_engine import (
|
|
ValidationLearner, RPAValidator, RPAExecutor,
|
|
load_rules, save_rules, TASK_ENDPOINT_MAP,
|
|
)
|
|
|
|
router = APIRouter(prefix="/api/rpa", tags=["rpa"])
|
|
|
|
# ── 인메모리 저장소 (재시작 시 rpa_rules.json로 복구) ─────────────────────
|
|
_validation_rules: Dict[str, List[Dict]] = {} # endpoint → rules (런타임)
|
|
_rpa_tasks: Dict[int, Dict] = {}
|
|
_rpa_executions: List[Dict] = []
|
|
_task_id_seq = 1
|
|
|
|
ITSM_BASE = os.getenv("ITSM_BASE_URL", "http://127.0.0.1:9001")
|
|
|
|
|
|
def _init_rules_from_file() -> None:
|
|
"""서비스 시작 시 rpa_rules.json에서 규칙 복구."""
|
|
global _validation_rules
|
|
loaded = load_rules()
|
|
if loaded:
|
|
_validation_rules.update(loaded)
|
|
total = sum(len(v) for v in loaded.values())
|
|
print(f"[RPA] 저장된 validation 규칙 복구: {len(loaded)}개 엔드포인트, {total}개 규칙")
|
|
|
|
|
|
def auto_learn() -> Dict:
|
|
"""서비스 시작 시 자동 학습 (규칙 파일 없을 때)."""
|
|
learner = ValidationLearner()
|
|
result = learner.learn_from_source()
|
|
rules = result["rules"]
|
|
_validation_rules.clear()
|
|
for r in rules:
|
|
ep = r["endpoint"]
|
|
_validation_rules.setdefault(ep, [])
|
|
if not any(x["field_name"] == r["field_name"] for x in _validation_rules[ep]):
|
|
_validation_rules[ep].append(r)
|
|
return result
|
|
|
|
|
|
# ── 초기화: 파일에서 규칙 복구, 없으면 즉시 학습 ─────────────────────────
|
|
_init_rules_from_file()
|
|
if not _validation_rules:
|
|
try:
|
|
auto_learn()
|
|
print("[RPA] 초기 Validation 자동 학습 완료")
|
|
except Exception as e:
|
|
print(f"[RPA] 초기 학습 실패 (수동으로 POST /api/rpa/validations/learn 호출): {e}")
|
|
|
|
|
|
# ── Schemas ──────────────────────────────────────────────────────────────────
|
|
|
|
class LearnRequest(BaseModel):
|
|
endpoints: str = "all"
|
|
overwrite: bool = True
|
|
|
|
class RPATaskCreate(BaseModel):
|
|
task_name: str
|
|
task_type: str
|
|
schedule: Optional[str] = None
|
|
payload_template: Dict[str, Any] = {}
|
|
is_active: bool = True
|
|
description: Optional[str] = None
|
|
|
|
class RPATaskOut(BaseModel):
|
|
id: int
|
|
task_name: str
|
|
task_type: str
|
|
schedule: Optional[str]
|
|
payload_template: Dict[str, Any]
|
|
is_active: bool
|
|
description: Optional[str]
|
|
created_at: str
|
|
last_run: Optional[str]
|
|
|
|
class ExecuteRequest(BaseModel):
|
|
task_type: str
|
|
payload: Dict[str, Any]
|
|
dry_run: bool = False
|
|
|
|
|
|
# ── Validation 학습 ──────────────────────────────────────────────────────────
|
|
|
|
@router.post("/validations/learn")
|
|
async def learn_validations(
|
|
req: LearnRequest,
|
|
current_user: User = Depends(get_current_user),
|
|
):
|
|
"""models.py + routers/ 소스 분석으로 validation 규칙 학습."""
|
|
learner = ValidationLearner()
|
|
try:
|
|
result = learner.learn_from_source()
|
|
except Exception as e:
|
|
raise HTTPException(500, f"소스 파싱 실패: {e}")
|
|
|
|
rules = result["rules"]
|
|
if req.overwrite:
|
|
_validation_rules.clear()
|
|
|
|
learned = 0
|
|
for r in rules:
|
|
ep = r["endpoint"]
|
|
_validation_rules.setdefault(ep, [])
|
|
existing = {x["field_name"] for x in _validation_rules[ep]}
|
|
if r["field_name"] not in existing:
|
|
_validation_rules[ep].append(r)
|
|
learned += 1
|
|
|
|
return {
|
|
"learned": learned,
|
|
"schemas": result["schemas"],
|
|
"endpoints_mapped": len(_validation_rules),
|
|
"total_rules": sum(len(v) for v in _validation_rules.values()),
|
|
"summary": {ep: len(rs) for ep, rs in list(_validation_rules.items())[:10]},
|
|
}
|
|
|
|
|
|
@router.get("/validations")
|
|
async def get_validations(
|
|
endpoint: Optional[str] = Query(None),
|
|
schema: Optional[str] = Query(None),
|
|
current_user: User = Depends(get_current_user),
|
|
):
|
|
"""학습된 validation 규칙 조회."""
|
|
if endpoint:
|
|
rules = _validation_rules.get(endpoint, [])
|
|
if schema:
|
|
rules = [r for r in rules if r.get("schema_class") == schema]
|
|
return {"endpoint": endpoint, "rule_count": len(rules), "rules": rules}
|
|
return {
|
|
"total_endpoints": len(_validation_rules),
|
|
"total_rules": sum(len(v) for v in _validation_rules.values()),
|
|
"endpoints": list(_validation_rules.keys()),
|
|
}
|
|
|
|
|
|
@router.get("/validations/schemas")
|
|
async def list_schemas(current_user: User = Depends(get_current_user)):
|
|
"""학습된 스키마 목록과 각 필드 수."""
|
|
schema_map: Dict[str, int] = {}
|
|
for rules in _validation_rules.values():
|
|
for r in rules:
|
|
sc = r.get("schema_class", "")
|
|
schema_map[sc] = schema_map.get(sc, 0) + 1
|
|
return {"schemas": schema_map}
|
|
|
|
|
|
# ── RPA 작업 관리 ─────────────────────────────────────────────────────────────
|
|
|
|
@router.post("/tasks", response_model=RPATaskOut)
|
|
async def create_rpa_task(
|
|
body: RPATaskCreate,
|
|
current_user: User = Depends(get_current_user),
|
|
):
|
|
global _task_id_seq
|
|
if body.task_type not in TASK_ENDPOINT_MAP:
|
|
raise HTTPException(400,
|
|
f"지원하지 않는 task_type. 허용값: {list(TASK_ENDPOINT_MAP.keys())}")
|
|
|
|
task = {
|
|
"id": _task_id_seq,
|
|
"task_name": body.task_name,
|
|
"task_type": body.task_type,
|
|
"schedule": body.schedule,
|
|
"payload_template": body.payload_template,
|
|
"is_active": body.is_active,
|
|
"description": body.description,
|
|
"created_at": datetime.now().isoformat(),
|
|
"last_run": None,
|
|
"created_by": current_user.username,
|
|
}
|
|
_rpa_tasks[_task_id_seq] = task
|
|
|
|
# APScheduler에 크론 등록
|
|
if body.schedule and body.is_active:
|
|
_register_cron(task)
|
|
|
|
_task_id_seq += 1
|
|
return task
|
|
|
|
|
|
@router.get("/tasks", response_model=List[RPATaskOut])
|
|
async def list_rpa_tasks(
|
|
is_active: Optional[bool] = Query(None),
|
|
task_type: Optional[str] = Query(None),
|
|
current_user: User = Depends(get_current_user),
|
|
):
|
|
tasks = list(_rpa_tasks.values())
|
|
if is_active is not None:
|
|
tasks = [t for t in tasks if t["is_active"] == is_active]
|
|
if task_type:
|
|
tasks = [t for t in tasks if t["task_type"] == task_type]
|
|
return tasks
|
|
|
|
|
|
@router.get("/tasks/{task_id}", response_model=RPATaskOut)
|
|
async def get_rpa_task(task_id: int, current_user: User = Depends(get_current_user)):
|
|
task = _rpa_tasks.get(task_id)
|
|
if not task:
|
|
raise HTTPException(404, "RPA 작업을 찾을 수 없습니다.")
|
|
return task
|
|
|
|
|
|
@router.put("/tasks/{task_id}", response_model=RPATaskOut)
|
|
async def update_rpa_task(
|
|
task_id: int,
|
|
body: RPATaskCreate,
|
|
current_user: User = Depends(get_current_user),
|
|
):
|
|
task = _rpa_tasks.get(task_id)
|
|
if not task:
|
|
raise HTTPException(404, "RPA 작업을 찾을 수 없습니다.")
|
|
|
|
# 기존 크론 제거
|
|
_unregister_cron(task_id)
|
|
|
|
task.update({
|
|
"task_name": body.task_name,
|
|
"task_type": body.task_type,
|
|
"schedule": body.schedule,
|
|
"payload_template": body.payload_template,
|
|
"is_active": body.is_active,
|
|
"description": body.description,
|
|
})
|
|
|
|
if body.schedule and body.is_active:
|
|
_register_cron(task)
|
|
|
|
return task
|
|
|
|
|
|
@router.patch("/tasks/{task_id}/toggle")
|
|
async def toggle_rpa_task(task_id: int, current_user: User = Depends(get_current_user)):
|
|
"""작업 활성/비활성 토글."""
|
|
task = _rpa_tasks.get(task_id)
|
|
if not task:
|
|
raise HTTPException(404, "RPA 작업을 찾을 수 없습니다.")
|
|
task["is_active"] = not task["is_active"]
|
|
if task["is_active"] and task.get("schedule"):
|
|
_register_cron(task)
|
|
else:
|
|
_unregister_cron(task_id)
|
|
return {"id": task_id, "is_active": task["is_active"]}
|
|
|
|
|
|
@router.delete("/tasks/{task_id}")
|
|
async def delete_rpa_task(task_id: int, current_user: User = Depends(get_current_user)):
|
|
if task_id not in _rpa_tasks:
|
|
raise HTTPException(404, "RPA 작업을 찾을 수 없습니다.")
|
|
_unregister_cron(task_id)
|
|
del _rpa_tasks[task_id]
|
|
return {"deleted": task_id}
|
|
|
|
|
|
# ── RPA 실행 ─────────────────────────────────────────────────────────────────
|
|
|
|
@router.post("/execute")
|
|
async def execute_rpa(
|
|
body: ExecuteRequest,
|
|
current_user: User = Depends(get_current_user),
|
|
):
|
|
"""단발성 RPA 실행 (validation → 실행 → 이력 기록)."""
|
|
global _rpa_executions
|
|
exec_id = len(_rpa_executions) + 1
|
|
started = datetime.now().isoformat()
|
|
|
|
# 해당 task_type의 엔드포인트 규칙 찾기
|
|
from core.rpa_engine import TASK_ENDPOINT_MAP
|
|
method_path = TASK_ENDPOINT_MAP.get(body.task_type)
|
|
if not method_path:
|
|
raise HTTPException(400, f"알 수 없는 task_type: {body.task_type}. 허용값: {list(TASK_ENDPOINT_MAP.keys())}")
|
|
|
|
ep_key = f"{method_path[0]} {method_path[1]}"
|
|
# path template → 실제 key (예: PATCH /api/tasks/{sr_id}/status → PATCH /api/tasks/status)
|
|
ep_key_norm = ep_key.split("{")[0].rstrip("/")
|
|
rules = _validation_rules.get(ep_key, []) or _validation_rules.get(ep_key_norm, [])
|
|
|
|
# Validation
|
|
validator = RPAValidator(rules)
|
|
errors = validator.validate(body.payload)
|
|
|
|
record: Dict[str, Any] = {
|
|
"execution_id": exec_id,
|
|
"task_type": body.task_type,
|
|
"dry_run": body.dry_run,
|
|
"validation_errors": errors,
|
|
"started_at": started,
|
|
"actor": current_user.username,
|
|
}
|
|
|
|
if errors:
|
|
record.update(status="VALIDATION_FAILED",
|
|
error=f"{len(errors)}개 validation 오류", result=None,
|
|
completed_at=datetime.now().isoformat())
|
|
_rpa_executions.append(record)
|
|
return record
|
|
|
|
if body.dry_run:
|
|
record.update(status="DRY_RUN_OK",
|
|
result={"message": "Validation 통과. dry_run=true이므로 실제 실행 생략."},
|
|
error=None, completed_at=datetime.now().isoformat())
|
|
_rpa_executions.append(record)
|
|
return record
|
|
|
|
# 실제 실행
|
|
executor = RPAExecutor(base_url=ITSM_BASE, token=_get_service_token(current_user))
|
|
try:
|
|
result = await executor.execute(body.task_type, body.payload, dry_run=False)
|
|
except Exception as e:
|
|
result = {"status": "FAILED", "error": str(e)}
|
|
|
|
record.update(
|
|
status=result.get("status", "FAILED"),
|
|
result=result.get("response"),
|
|
error=result.get("error"),
|
|
completed_at=datetime.now().isoformat(),
|
|
)
|
|
_rpa_executions.append(record)
|
|
return record
|
|
|
|
|
|
@router.post("/tasks/{task_id}/run")
|
|
async def run_rpa_task(
|
|
task_id: int,
|
|
dry_run: bool = Query(False),
|
|
current_user: User = Depends(get_current_user),
|
|
):
|
|
"""등록된 RPA 작업 즉시 실행."""
|
|
task = _rpa_tasks.get(task_id)
|
|
if not task:
|
|
raise HTTPException(404, "RPA 작업을 찾을 수 없습니다.")
|
|
if not task["is_active"]:
|
|
raise HTTPException(400, "비활성 작업입니다. 먼저 활성화하세요.")
|
|
|
|
req = ExecuteRequest(task_type=task["task_type"],
|
|
payload=task["payload_template"], dry_run=dry_run)
|
|
result = await execute_rpa(req, current_user)
|
|
_rpa_tasks[task_id]["last_run"] = datetime.now().isoformat()
|
|
return result
|
|
|
|
|
|
# ── 실행 이력 ──────────────────────────────────────────────────────────────────
|
|
|
|
@router.get("/executions")
|
|
async def list_executions(
|
|
status: Optional[str] = Query(None),
|
|
task_type: Optional[str] = Query(None),
|
|
page: int = Query(1, ge=1),
|
|
size: int = Query(20, ge=1, le=100),
|
|
current_user: User = Depends(get_current_user),
|
|
):
|
|
execs = list(_rpa_executions)
|
|
if status:
|
|
execs = [e for e in execs if e.get("status") == status]
|
|
if task_type:
|
|
execs = [e for e in execs if e.get("task_type") == task_type]
|
|
total = len(execs)
|
|
start = (page - 1) * size
|
|
return {"total": total, "page": page, "size": size,
|
|
"items": list(reversed(execs))[start:start + size]}
|
|
|
|
|
|
@router.get("/executions/{execution_id}")
|
|
async def get_execution(execution_id: int, current_user: User = Depends(get_current_user)):
|
|
for e in _rpa_executions:
|
|
if e["execution_id"] == execution_id:
|
|
return e
|
|
raise HTTPException(404, "실행 이력을 찾을 수 없습니다.")
|
|
|
|
|
|
@router.get("/status")
|
|
async def rpa_status(current_user: User = Depends(get_current_user)):
|
|
"""RPA 시스템 현황 요약."""
|
|
return {
|
|
"validation_endpoints": len(_validation_rules),
|
|
"validation_rules": sum(len(v) for v in _validation_rules.values()),
|
|
"tasks_total": len(_rpa_tasks),
|
|
"tasks_active": sum(1 for t in _rpa_tasks.values() if t["is_active"]),
|
|
"executions_total": len(_rpa_executions),
|
|
"executions_success": sum(1 for e in _rpa_executions if e.get("status") == "SUCCESS"),
|
|
"executions_failed": sum(1 for e in _rpa_executions
|
|
if e.get("status") in ("FAILED", "VALIDATION_FAILED")),
|
|
"supported_task_types": list(TASK_ENDPOINT_MAP.keys()),
|
|
}
|
|
|
|
|
|
# ── APScheduler 연동 ──────────────────────────────────────────────────────────
|
|
|
|
def _register_cron(task: Dict) -> None:
|
|
"""APScheduler에 크론 잡 등록."""
|
|
try:
|
|
from core.scheduler import scheduler
|
|
cron = task.get("schedule", "")
|
|
if not cron:
|
|
return
|
|
parts = cron.split()
|
|
if len(parts) < 5:
|
|
return
|
|
minute, hour, day, month, day_of_week = parts[:5]
|
|
job_id = f"rpa_task_{task['id']}"
|
|
scheduler.add_job(
|
|
_run_task_background,
|
|
trigger="cron",
|
|
id=job_id,
|
|
replace_existing=True,
|
|
minute=minute, hour=hour,
|
|
day=day, month=month,
|
|
day_of_week=day_of_week,
|
|
args=[task["id"]],
|
|
)
|
|
print(f"[RPA] 크론 등록: {job_id} ({cron})")
|
|
except Exception as e:
|
|
print(f"[RPA] 크론 등록 실패 (task_id={task['id']}): {e}")
|
|
|
|
|
|
def _unregister_cron(task_id: int) -> None:
|
|
try:
|
|
from core.scheduler import scheduler
|
|
job_id = f"rpa_task_{task_id}"
|
|
if scheduler.get_job(job_id):
|
|
scheduler.remove_job(job_id)
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
def _run_task_background(task_id: int) -> None:
|
|
"""크론에 의해 백그라운드에서 호출되는 RPA 실행 함수."""
|
|
import asyncio
|
|
task = _rpa_tasks.get(task_id)
|
|
if not task or not task["is_active"]:
|
|
return
|
|
|
|
exec_id = len(_rpa_executions) + 1
|
|
started = datetime.now().isoformat()
|
|
ep = TASK_ENDPOINT_MAP.get(task["task_type"], ("", ""))[1]
|
|
ep_key = f"{TASK_ENDPOINT_MAP.get(task['task_type'], ('POST',''))[0]} {ep}"
|
|
rules = _validation_rules.get(ep_key, [])
|
|
validator = RPAValidator(rules)
|
|
errors = validator.validate(task["payload_template"])
|
|
|
|
record: Dict[str, Any] = {
|
|
"execution_id": exec_id,
|
|
"task_type": task["task_type"],
|
|
"dry_run": False,
|
|
"validation_errors": errors,
|
|
"started_at": started,
|
|
"actor": "rpa-scheduler",
|
|
}
|
|
|
|
if errors:
|
|
record.update(status="VALIDATION_FAILED",
|
|
error=f"{len(errors)}개 validation 오류", result=None,
|
|
completed_at=datetime.now().isoformat())
|
|
else:
|
|
async def _run():
|
|
executor = RPAExecutor(base_url=ITSM_BASE, token="")
|
|
return await executor.execute(task["task_type"], task["payload_template"])
|
|
try:
|
|
loop = asyncio.new_event_loop()
|
|
result = loop.run_until_complete(_run())
|
|
loop.close()
|
|
except Exception as e:
|
|
result = {"status": "FAILED", "error": str(e)}
|
|
|
|
record.update(
|
|
status=result.get("status", "FAILED"),
|
|
result=result.get("response"),
|
|
error=result.get("error"),
|
|
completed_at=datetime.now().isoformat(),
|
|
)
|
|
|
|
_rpa_executions.append(record)
|
|
_rpa_tasks[task_id]["last_run"] = datetime.now().isoformat()
|
|
print(f"[RPA Scheduler] task_id={task_id} status={record['status']}")
|
|
|
|
|
|
def _get_service_token(user: User) -> str:
|
|
"""서비스 계정용 토큰 생성 (내부 API 호출용)."""
|
|
from core.auth import create_access_token
|
|
return create_access_token({"sub": user.username, "role": user.role})
|