[고객 셀프서비스 포털] - routers/customer_portal.py: SR접수/추적/AI FAQ자가해결/카탈로그/만족도/통계 POST /api/portal/faq/suggest — KB+Ollama 기반 SR 접수 전 자가해결 유도 [그룹웨어 전자결재 연동] - routers/groupware.py: 카카오워크/네이버웍스/한컴/Custom 웹훅 POST /api/groupware/send-approval → 결재 발송 POST /api/groupware/callback → 승인/반려 콜백 → SR 상태 자동 갱신 [SIEM 보안 이벤트 연동] - routers/siem.py: Elasticsearch/Splunk HEC/OpenSearch POST /api/siem/alert/receive → SIEM 경보 → 인시던트 자동 생성 [네트워크 토폴로지 시각화] - routers/topology.py: CMDB CI 의존관계 D3.js 인터랙티브 그래프 GET /api/topology/page — 드래그/줌/헬스오버레이 뷰어 [포트폴리오 + 리소스/인력 관리] - routers/portfolio.py: 다중 프로젝트 포트폴리오 대시보드 + 인원 배치(M/M) + 역량 매핑 [Zero Trust + Kubernetes + ERP] - routers/infra_ext.py: - Zero Trust 세션 재검증 (위험점수 70 이상 → 강제 재인증) - K8s pods/services/nodes API 연동 - ERP 예산 동기화 [API 명세서] - manual/16_API_명세서.md: 전체 588개 라우트 도메인별 정리 [버그 수정] - customer_portal.py: ServiceCatalog→ServiceItem, KBDocument.content→solution/symptoms - customer_portal.py: catalog is_active→status="ACTIVE" Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
336 lines
13 KiB
Python
336 lines
13 KiB
Python
"""
|
|
그룹웨어 전자결재 연동 API
|
|
|
|
지원 플랫폼:
|
|
- 카카오워크 (KAKAOWORK_BOT_TOKEN)
|
|
- 네이버웍스 (NAVER_WORKS_BOT_ID / NAVER_WORKS_TOKEN)
|
|
- 한컴오피스 (HANCOM_WEBHOOK_URL)
|
|
- 사용자 정의 웹훅 (CUSTOM_APPROVAL_WEBHOOK_URL)
|
|
|
|
기능:
|
|
1. SR 승인 요청 → 그룹웨어 결재 라인으로 발송
|
|
2. 그룹웨어 승인/반려 콜백 → GUARDiA SR 상태 자동 갱신
|
|
3. 결재 현황 조회
|
|
|
|
환경변수:
|
|
GROUPWARE_TYPE = kakao|naver|hancom|custom
|
|
KAKAOWORK_BOT_TOKEN = ...
|
|
NAVER_WORKS_BOT_ID = ...
|
|
NAVER_WORKS_TOKEN = ...
|
|
HANCOM_WEBHOOK_URL = ...
|
|
CUSTOM_APPROVAL_WEBHOOK_URL = ...
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import hmac
|
|
import json
|
|
import logging
|
|
import os
|
|
from datetime import datetime
|
|
from typing import Any, Optional
|
|
|
|
import httpx
|
|
from fastapi import APIRouter, BackgroundTasks, Depends, Header, HTTPException, Request
|
|
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 SRRequest, SRStatus, ApprovalFlow, User, UserRole
|
|
|
|
logger = logging.getLogger(__name__)
|
|
router = APIRouter(prefix="/api/groupware", tags=["groupware"])
|
|
|
|
GROUPWARE_TYPE = os.getenv("GROUPWARE_TYPE", "")
|
|
KAKAO_TOKEN = os.getenv("KAKAOWORK_BOT_TOKEN", "")
|
|
NAVER_BOT_ID = os.getenv("NAVER_WORKS_BOT_ID", "")
|
|
NAVER_TOKEN = os.getenv("NAVER_WORKS_TOKEN", "")
|
|
HANCOM_URL = os.getenv("HANCOM_WEBHOOK_URL", "")
|
|
CUSTOM_URL = os.getenv("CUSTOM_APPROVAL_WEBHOOK_URL", "")
|
|
WEBHOOK_SECRET = os.getenv("GROUPWARE_WEBHOOK_SECRET", "guardia-secret")
|
|
|
|
# 결재 요청 이력 (운영 시 DB 테이블로 이전)
|
|
_approval_requests: dict[str, dict] = {}
|
|
|
|
|
|
class ApprovalSendRequest(BaseModel):
|
|
sr_id: str
|
|
approver: str # 결재자 사용자명 또는 이메일
|
|
message: Optional[str] = None
|
|
platform: Optional[str] = None # None이면 환경변수 GROUPWARE_TYPE 사용
|
|
|
|
|
|
class CallbackRequest(BaseModel):
|
|
action: str # approved | rejected
|
|
sr_id: str
|
|
approver: str
|
|
comment: Optional[str] = None
|
|
signature: Optional[str] = None # HMAC-SHA256 검증용
|
|
|
|
|
|
# ── 그룹웨어별 메시지 발송 ────────────────────────────────────────────────────
|
|
|
|
async def _send_kakao(sr_id: str, title: str, approver: str, message: str):
|
|
"""카카오워크 결재 메시지 발송."""
|
|
if not KAKAO_TOKEN:
|
|
logger.debug("KAKAOWORK_BOT_TOKEN 미설정")
|
|
return False
|
|
payload = {
|
|
"conversationId": approver,
|
|
"message": {
|
|
"text": f"[GUARDiA 결재 요청]\n{message}",
|
|
"blocks": [
|
|
{"type": "header", "text": f"📋 결재 요청: {sr_id}", "style": "yellow"},
|
|
{"type": "description", "term": "SR", "content": {"type": "text", "text": title}},
|
|
{"type": "button", "text": "승인", "style": "primary",
|
|
"action": {"type": "call_modal", "value": f"approve:{sr_id}"}},
|
|
{"type": "button", "text": "반려", "style": "default",
|
|
"action": {"type": "call_modal", "value": f"reject:{sr_id}"}},
|
|
]
|
|
}
|
|
}
|
|
try:
|
|
async with httpx.AsyncClient(timeout=10.0) as c:
|
|
r = await c.post(
|
|
"https://api.kakaowork.com/v1/messages.send",
|
|
headers={"Authorization": f"Bearer {KAKAO_TOKEN}"},
|
|
json=payload,
|
|
)
|
|
return r.status_code == 200
|
|
except Exception as e:
|
|
logger.warning("카카오워크 발송 실패: %s", e)
|
|
return False
|
|
|
|
|
|
async def _send_naver_works(sr_id: str, title: str, approver: str, message: str):
|
|
"""네이버웍스 결재 메시지 발송."""
|
|
if not NAVER_BOT_ID or not NAVER_TOKEN:
|
|
return False
|
|
payload = {
|
|
"content": {
|
|
"type": "flex",
|
|
"altText": f"[GUARDiA 결재 요청] {sr_id}",
|
|
"contents": {
|
|
"type": "bubble",
|
|
"header": {"type": "box", "layout": "vertical",
|
|
"contents": [{"type": "text", "text": f"📋 결재 요청", "weight": "bold"}]},
|
|
"body": {"type": "box", "layout": "vertical",
|
|
"contents": [{"type": "text", "text": f"SR: {sr_id}\n{message[:200]}"}]},
|
|
"footer": {"type": "box", "layout": "horizontal", "contents": [
|
|
{"type": "button", "style": "primary", "action": {"type": "message", "label": "승인", "text": f"/approve {sr_id}"}},
|
|
{"type": "button", "style": "secondary", "action": {"type": "message", "label": "반려", "text": f"/reject {sr_id}"}},
|
|
]},
|
|
}
|
|
}
|
|
}
|
|
try:
|
|
async with httpx.AsyncClient(timeout=10.0) as c:
|
|
r = await c.post(
|
|
f"https://www.worksapis.com/v1.0/bots/{NAVER_BOT_ID}/users/{approver}/messages",
|
|
headers={"Authorization": f"Bearer {NAVER_TOKEN}", "Content-Type": "application/json"},
|
|
json=payload,
|
|
)
|
|
return r.status_code in (200, 201)
|
|
except Exception as e:
|
|
logger.warning("네이버웍스 발송 실패: %s", e)
|
|
return False
|
|
|
|
|
|
async def _send_hancom(sr_id: str, title: str, approver: str, message: str):
|
|
"""한컴오피스/그룹웨어 웹훅 발송."""
|
|
if not HANCOM_URL:
|
|
return False
|
|
payload = {
|
|
"event": "approval_request",
|
|
"sr_id": sr_id,
|
|
"title": title,
|
|
"approver": approver,
|
|
"message": message,
|
|
"callback_url": f"{os.getenv('GUARDIA_BASE_URL','http://localhost:8001')}/api/groupware/callback",
|
|
}
|
|
try:
|
|
async with httpx.AsyncClient(timeout=10.0) as c:
|
|
r = await c.post(HANCOM_URL, json=payload)
|
|
return r.status_code in (200, 201, 202)
|
|
except Exception as e:
|
|
logger.warning("한컴 발송 실패: %s", e)
|
|
return False
|
|
|
|
|
|
async def _send_custom(sr_id: str, title: str, approver: str, message: str):
|
|
"""사용자 정의 그룹웨어 웹훅."""
|
|
if not CUSTOM_URL:
|
|
return False
|
|
payload = {
|
|
"type": "approval_request",
|
|
"sr_id": sr_id,
|
|
"title": title,
|
|
"approver": approver,
|
|
"message": message,
|
|
"timestamp":datetime.utcnow().isoformat(),
|
|
"callback_url": f"{os.getenv('GUARDIA_BASE_URL','http://localhost:8001')}/api/groupware/callback",
|
|
}
|
|
# HMAC 서명
|
|
sig = hmac.new(WEBHOOK_SECRET.encode(), json.dumps(payload, sort_keys=True).encode(), hashlib.sha256).hexdigest()
|
|
try:
|
|
async with httpx.AsyncClient(timeout=10.0) as c:
|
|
r = await c.post(CUSTOM_URL, json=payload, headers={"X-Signature": sig})
|
|
return r.status_code in (200, 201, 202)
|
|
except Exception as e:
|
|
logger.warning("커스텀 웹훅 발송 실패: %s", e)
|
|
return False
|
|
|
|
|
|
async def _dispatch(platform: str, sr_id: str, title: str, approver: str, message: str) -> bool:
|
|
"""플랫폼에 따라 결재 메시지 발송."""
|
|
p = (platform or GROUPWARE_TYPE or "custom").lower()
|
|
if p == "kakao":
|
|
return await _send_kakao(sr_id, title, approver, message)
|
|
elif p == "naver":
|
|
return await _send_naver_works(sr_id, title, approver, message)
|
|
elif p == "hancom":
|
|
return await _send_hancom(sr_id, title, approver, message)
|
|
else:
|
|
return await _send_custom(sr_id, title, approver, message)
|
|
|
|
|
|
# ── 결재 요청 발송 API ────────────────────────────────────────────────────────
|
|
|
|
@router.post("/send-approval")
|
|
async def send_approval(
|
|
body: ApprovalSendRequest,
|
|
bg: BackgroundTasks,
|
|
db: AsyncSession = Depends(get_db),
|
|
cu: User = Depends(get_current_user),
|
|
):
|
|
"""SR 승인 요청을 그룹웨어로 발송."""
|
|
sr = (await db.execute(select(SRRequest).where(SRRequest.sr_id == body.sr_id))).scalars().first()
|
|
if not sr:
|
|
raise HTTPException(404, f"SR {body.sr_id}를 찾을 수 없습니다.")
|
|
|
|
platform = body.platform or GROUPWARE_TYPE
|
|
message = body.message or (
|
|
f"SR: {sr.sr_id}\n제목: {sr.title}\n요청자: {sr.requested_by}\n"
|
|
f"우선순위: {sr.priority}\n\n처리 요청드립니다."
|
|
)
|
|
|
|
# 발송 이력 저장
|
|
_approval_requests[sr.sr_id] = {
|
|
"sr_id": sr.sr_id,
|
|
"approver": body.approver,
|
|
"platform": platform,
|
|
"sent_at": datetime.utcnow().isoformat(),
|
|
"status": "PENDING",
|
|
}
|
|
|
|
# 백그라운드 발송
|
|
async def _bg_send():
|
|
ok = await _dispatch(platform, sr.sr_id, sr.title, body.approver, message)
|
|
_approval_requests[sr.sr_id]["sent"] = ok
|
|
logger.info("그룹웨어 결재 발송: sr=%s platform=%s ok=%s", sr.sr_id, platform, ok)
|
|
|
|
bg.add_task(_bg_send)
|
|
|
|
return {
|
|
"message": f"{platform or 'custom'} 그룹웨어로 결재 요청을 발송합니다.",
|
|
"sr_id": sr.sr_id,
|
|
"approver": body.approver,
|
|
"platform": platform or "custom",
|
|
}
|
|
|
|
|
|
# ── 그룹웨어 콜백 수신 (승인/반려) ───────────────────────────────────────────
|
|
|
|
@router.post("/callback")
|
|
async def groupware_callback(
|
|
body: CallbackRequest,
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""그룹웨어에서 승인/반려 콜백 수신 → SR 상태 자동 갱신."""
|
|
if body.action not in ("approved", "rejected"):
|
|
raise HTTPException(400, f"action은 approved|rejected 이어야 합니다.")
|
|
|
|
sr = (await db.execute(select(SRRequest).where(SRRequest.sr_id == body.sr_id))).scalars().first()
|
|
if not sr:
|
|
raise HTTPException(404, f"SR {body.sr_id}를 찾을 수 없습니다.")
|
|
|
|
# 승인/반려 처리
|
|
from models import ApprovalResult, compute_log_hash, AuditLog
|
|
result = ApprovalResult.APPROVED if body.action == "approved" else ApprovalResult.REJECTED
|
|
|
|
apv = ApprovalFlow(
|
|
sr_id = body.sr_id,
|
|
approver = body.approver,
|
|
result = result,
|
|
comment = f"[그룹웨어 결재] {body.comment or ''}",
|
|
decided_at = datetime.now(),
|
|
)
|
|
db.add(apv)
|
|
|
|
old_status = sr.status
|
|
if body.action == "approved":
|
|
sr.status = SRStatus.APPROVED
|
|
else:
|
|
sr.status = SRStatus.REJECTED
|
|
sr.updated_at = datetime.now()
|
|
|
|
# 감사 로그
|
|
from sqlalchemy import select as sel
|
|
last_log = (await db.execute(
|
|
sel(AuditLog).where(AuditLog.sr_id == body.sr_id).order_by(AuditLog.id.desc()).limit(1)
|
|
)).scalars().first()
|
|
prev_hash = last_log.log_hash if last_log else None
|
|
ts = datetime.now().isoformat()
|
|
db.add(AuditLog(
|
|
sr_id = body.sr_id,
|
|
actor = f"[그룹웨어]{body.approver}",
|
|
action = "SR_APPROVED" if body.action == "approved" else "SR_REJECTED",
|
|
detail = f"그룹웨어 결재: {body.action} | {body.comment or ''}",
|
|
prev_hash = prev_hash,
|
|
log_hash = compute_log_hash(prev_hash, body.approver, body.action, "", ts),
|
|
))
|
|
|
|
# 이력 갱신
|
|
if body.sr_id in _approval_requests:
|
|
_approval_requests[body.sr_id]["status"] = body.action.upper()
|
|
_approval_requests[body.sr_id]["decided_at"] = datetime.utcnow().isoformat()
|
|
|
|
await db.commit()
|
|
|
|
return {
|
|
"message": f"SR {body.sr_id} — {body.action} 처리 완료",
|
|
"old_status": old_status,
|
|
"new_status": sr.status,
|
|
}
|
|
|
|
|
|
# ── 결재 현황 조회 ────────────────────────────────────────────────────────────
|
|
|
|
@router.get("/approvals")
|
|
async def list_approvals(_u: User = Depends(get_current_user)):
|
|
"""그룹웨어 결재 발송 이력 조회."""
|
|
return {
|
|
"enabled": bool(GROUPWARE_TYPE or KAKAO_TOKEN or NAVER_BOT_ID or HANCOM_URL or CUSTOM_URL),
|
|
"platform": GROUPWARE_TYPE or "미설정",
|
|
"approvals": list(_approval_requests.values()),
|
|
}
|
|
|
|
|
|
@router.get("/config")
|
|
async def groupware_config(_u: User = Depends(get_current_user)):
|
|
"""그룹웨어 연동 설정 현황 (민감 정보 제외)."""
|
|
return {
|
|
"configured_platforms": [
|
|
p for p, flag in [
|
|
("kakao", bool(KAKAO_TOKEN)),
|
|
("naver", bool(NAVER_BOT_ID and NAVER_TOKEN)),
|
|
("hancom", bool(HANCOM_URL)),
|
|
("custom", bool(CUSTOM_URL)),
|
|
] if flag
|
|
],
|
|
"default_platform": GROUPWARE_TYPE or "none",
|
|
"callback_url": f"{os.getenv('GUARDIA_BASE_URL','http://localhost:8001')}/api/groupware/callback",
|
|
}
|