라우터 (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>
163 lines
5.9 KiB
Python
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
|
|
]
|