zioinfo-mail/workspace/guardia-itsm/routers/groupware.py
DESKTOP-TKLFCPR\ython cfe2901a55 refactor(structure): consolidate all projects under workspace/
- 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>
2026-05-31 23:50:56 +09:00

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