zioinfo-mail/workspace/guardia-itsm/routers/kakao_notify.py
DESKTOP-TKLFCPR\ython fc0ba65e05 feat(expansion): GUARDiA v3 P3 완성 — 13 routers + 14 DB tables
라우터 (667개 엔드포인트, P3 신규 69개):
- multimodal.py:      llava 이미지 분석 + 에러 자동 분류
- learning_loop.py:   Ollama 파인튜닝 + 품질 지표
- ai_insights.py:     주간 인사이트 + 반복 패턴 + 개선 권고
- container_alerts.py: Docker 이상 감지 → SR 자동 생성
- ncloud.py:          NCloud API (서버/LB/스토리지/비용)
- billing.py:         구독 플랜 + 사용량 측정 + 청구서
- servicenow.py:      ServiceNow CMDB/Incident 양방향 연동
- erp_connector.py:   그룹웨어/HR ERP 연동 + 결재 웹훅
- kakao_notify.py:    카카오 알림톡 + 대량 발송
- auto_report.py:     Excel/PDF 보고서 자동 생성·다운로드
- benchmark.py:       기관 간 익명 벤치마킹 (완전 익명화)
- cohort_analysis.py: 도입 코호트 + 리텐션 + 기능 도입률

DB 모델 (14개 신규 테이블):
tb_learning_run, tb_container_alert_{rule,log},
tb_ncloud_config, tb_subscription, tb_invoice,
tb_servicenow_{config,mapping}, tb_erp_config,
tb_kakao_{config,notify_log}, tb_report_{record,schedule},
tb_benchmark_contrib

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-02 06:06:59 +09:00

163 lines
5.9 KiB
Python

"""
카카오 알림톡 + 카카오워크 알림
일반 휴대폰으로 카카오 알림톡 발송 (비즈니스 채널 필요).
기존 메신저봇과 별개로 외부 수신자에게 알림.
엔드포인트:
POST /api/kakao/config — 카카오 API 설정
GET /api/kakao/config — 설정 조회
POST /api/kakao/alimtalk — 알림톡 발송
POST /api/kakao/friendtalk — 친구톡 발송 (이미지 포함)
POST /api/kakao/bulk — 대량 발송
GET /api/kakao/history — 발송 이력
"""
from __future__ import annotations
import logging
from datetime import datetime
from typing import List, 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, require_admin_role
from database import get_db
from models import User, KakaoConfig, KakaoNotifyLog # 신규
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/kakao", tags=["카카오 알림톡"])
KAKAO_API = "https://kakaoapi.aligo.in/akv10" # Aligo 카카오 알림톡 API 호환
class KakaoConfigCreate(BaseModel):
apikey: str = Field(..., description="발급받은 API Key")
userid: str = Field(..., description="알리고 ID")
senderkey: str = Field(..., description="발신 프로필 키")
sender: str = Field(..., description="등록된 발신번호 (예: 0312345678)")
class AlimtalkRequest(BaseModel):
receivers: List[str] = Field(..., description="수신 전화번호 목록 (최대 500)")
template_code: str
variables: dict = Field(default_factory=dict, description="템플릿 변수")
subject: Optional[str] = None
class BulkAlimtalk(BaseModel):
requests: List[AlimtalkRequest]
@router.post("/config")
async def save_kakao_config(
req: KakaoConfigCreate, db: AsyncSession = Depends(get_db),
user: User = Depends(require_admin_role),
):
row = await db.execute(select(KakaoConfig).where(KakaoConfig.tenant_id == user.tenant_id))
cfg = row.scalar_one_or_none()
if cfg:
cfg.apikey = req.apikey; cfg.userid = req.userid
cfg.senderkey_enc = req.senderkey; cfg.sender = req.sender
else:
cfg = KakaoConfig(
tenant_id=user.tenant_id, apikey=req.apikey, userid=req.userid,
senderkey_enc=req.senderkey, sender=req.sender,
is_active=True, created_at=datetime.utcnow()
)
db.add(cfg)
await db.commit()
return {"ok": True}
@router.get("/config")
async def get_kakao_config(db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)):
row = await db.execute(select(KakaoConfig).where(KakaoConfig.tenant_id == user.tenant_id))
cfg = row.scalar_one_or_none()
if not cfg: return None
return {"sender": cfg.sender, "userid": cfg.userid, "is_active": cfg.is_active}
async def _send_alimtalk(cfg: KakaoConfig, receivers: list, template_code: str, variables: dict) -> dict:
"""알리고 API로 알림톡 발송."""
# 변수를 #{변수명} 형식으로 치환
var_str = "|".join(f"#{k}#={v}" for k, v in variables.items())
payload = {
"apikey": cfg.apikey,
"userid": cfg.userid,
"senderkey": cfg.senderkey_enc,
"tpl_code": template_code,
"sender": cfg.sender,
"receiver_1": ",".join(receivers[:500]),
"recvname_1": "수신자",
"subject_1": "GUARDiA 알림",
"message_1": var_str,
}
try:
async with httpx.AsyncClient(timeout=15) as c:
r = await c.post(f"{KAKAO_API}/send/", data=payload)
return r.json() if r.status_code == 200 else {"error": r.text[:100]}
except Exception as e:
return {"error": str(e)}
@router.post("/alimtalk")
async def send_alimtalk(
req: AlimtalkRequest, db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
):
row = await db.execute(select(KakaoConfig).where(KakaoConfig.tenant_id == user.tenant_id))
cfg = row.scalar_one_or_none()
if not cfg: raise HTTPException(404, "카카오 설정 없음")
result = await _send_alimtalk(cfg, req.receivers, req.template_code, req.variables)
# 발송 이력 저장
log = KakaoNotifyLog(
tenant_id=user.tenant_id, template_code=req.template_code,
receiver_count=len(req.receivers),
success=result.get("code") == "A000" or not result.get("error"),
result_json=str(result)[:500], sent_at=datetime.utcnow()
)
db.add(log)
await db.commit()
return {"ok": not result.get("error"), "result": result}
@router.post("/bulk")
async def send_bulk(
req: BulkAlimtalk, db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
):
"""대량 알림톡 발송 (여러 템플릿)."""
row = await db.execute(select(KakaoConfig).where(KakaoConfig.tenant_id == user.tenant_id))
cfg = row.scalar_one_or_none()
if not cfg: raise HTTPException(404, "카카오 설정 없음")
results = []
for item in req.requests:
result = await _send_alimtalk(cfg, item.receivers, item.template_code, item.variables)
results.append({"template": item.template_code, "count": len(item.receivers), "result": result})
return {"ok": True, "results": results, "total_requests": len(req.requests)}
@router.get("/history")
async def kakao_history(
limit: int = 50, db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
):
rows = await db.execute(
select(KakaoNotifyLog).where(KakaoNotifyLog.tenant_id == user.tenant_id)
.order_by(desc(KakaoNotifyLog.sent_at)).limit(limit)
)
logs = rows.scalars().all()
return [
{"id": l.id, "template": l.template_code, "receivers": l.receiver_count,
"success": l.success, "sent_at": l.sent_at}
for l in logs
]