""" 그룹웨어 전자결재 연동 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", }