guardia-itsm/routers/citizen_portal.py

415 lines
16 KiB
Python

from __future__ import annotations
import base64
import hashlib
import logging
import os
from datetime import datetime, timedelta
from typing import Optional, List
from uuid import uuid4
import httpx
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel, Field
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 (
User, CitizenReport, SatisfactionSurvey, ServiceStatusPage,
KBDocument, KakaoNotifyLog, SRRequest, SRStatus,
)
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/citizen", tags=["Citizen Portal"])
_STATUS_LABEL = {
"RECEIVED": "접수 완료",
"ASSIGNED": "담당자 배정",
"IN_PROGRESS": "처리 중",
"RESOLVED": "처리 완료",
}
_STATUS_PROGRESS = {"RECEIVED": 15, "ASSIGNED": 40, "IN_PROGRESS": 70, "RESOLVED": 100}
_VALID_CATEGORIES = {"pc", "printer", "network", "phone", "etc"}
# 간단한 rate limit 버킷
_RATE_BUCKET: dict[str, list[float]] = {}
_RATE_LIMIT = 30
_RATE_WINDOW = 60.0
def _rate_limit(key: str) -> None:
import time
now = time.monotonic()
hits = [t for t in _RATE_BUCKET.get(key, []) if now - t < _RATE_WINDOW]
if len(hits) >= _RATE_LIMIT:
raise HTTPException(429, "요청이 너무 많습니다. 잠시 후 다시 시도해 주세요.")
hits.append(now)
_RATE_BUCKET[key] = hits
def _enc_key() -> bytes:
raw = os.environ.get("GUARDIA_ENC_KEY", "guardia-demo-key-32bytes-padding!").encode()[:32]
return raw.ljust(32, b"\x00")
def _encrypt_contact(plain: Optional[str]) -> Optional[str]:
if not plain:
return None
try:
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
nonce = os.urandom(12)
ct = AESGCM(_enc_key()).encrypt(nonce, plain.encode(), None)
return base64.b64encode(nonce + ct).decode()
except Exception:
return None
def _decrypt_contact(enc: Optional[str]) -> Optional[str]:
if not enc:
return None
try:
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
raw = base64.b64decode(enc)
return AESGCM(_enc_key()).decrypt(raw[:12], raw[12:], None).decode()
except Exception:
return None
def _mask_contact(enc: Optional[str]) -> str:
plain = _decrypt_contact(enc)
if not plain:
return "-"
if "@" in plain:
local, _, domain = plain.partition("@")
head = local[:2] if len(local) > 2 else local[:1]
return f"{head}***@{domain}"
digits = plain.replace("-", "").replace(" ", "")
if len(digits) >= 7:
return f"{digits[:3]}****{digits[-4:]}"
return plain[:1] + "***"
def _new_ticket_id() -> str:
return f"CTZ-{datetime.now().strftime('%Y%m%d')}-{str(uuid4())[:8].upper()}"
def _tenant(user: User) -> str:
return user.inst_code or str(user.id)
async def _ollama(prompt: str) -> Optional[str]:
try:
async with httpx.AsyncClient(timeout=20) as c:
r = await c.post(
"http://localhost:11434/api/generate",
json={"model": "llama3", "prompt": prompt, "stream": False},
)
return r.json().get("response", "").strip()
except Exception:
return None
# ── Pydantic 스키마 ────────────────────────────────────────────────────────────
class ReportIn(BaseModel):
tenant_id: str
reporter_name: Optional[str] = "익명"
reporter_contact: Optional[str] = None
issue_description: str = Field(..., min_length=2)
category: str = "etc"
location: Optional[str] = None
photo_url: Optional[str] = None
contact_method: str = "none"
class FAQQueryIn(BaseModel):
tenant_id: Optional[str] = None
question: str = Field(..., min_length=2)
class SurveyIn(BaseModel):
ticket_id: str
score: int = Field(..., ge=1, le=5)
comment: Optional[str] = None
class QRGenerateIn(BaseModel):
tenant_id: str
location_code: str
location_name: Optional[str] = None
class NotifySMSIn(BaseModel):
ticket_id: str
message: Optional[str] = None
channel: str = "sms"
# ══════════════════════════════════════════════════════════════════════════════
# Public 엔드포인트 (무인증)
# ══════════════════════════════════════════════════════════════════════════════
@router.post("/report", status_code=201, summary="민원인 IT 문제 직접 신고")
async def citizen_create_report(
body: ReportIn,
db: AsyncSession = Depends(get_db),
):
_rate_limit(f"report:{body.tenant_id}")
category = body.category if body.category in _VALID_CATEGORIES else "etc"
ticket_id = _new_ticket_id()
report = CitizenReport(
tenant_id=body.tenant_id,
ticket_id=ticket_id,
reporter_name=(body.reporter_name or "익명")[:100],
reporter_contact=_encrypt_contact(body.reporter_contact),
issue_description=body.issue_description.strip(),
category=category,
location=(body.location or "")[:200] or None,
photo_url=(body.photo_url or "")[:500] or None,
contact_method=body.contact_method,
status="RECEIVED",
notify_subscribed=body.contact_method != "none",
)
db.add(report)
await db.commit()
await db.refresh(report)
# ITSM SR 자동 생성
sr_id_str = None
try:
sr_id = f"SR-{datetime.now().strftime('%Y%m%d')}-{str(uuid4())[:6].upper()}"
cat_label = {"pc": "PC", "printer": "프린터", "network": "네트워크", "phone": "전화", "etc": "기타"}.get(category, "기타")
sr = SRRequest(
sr_id=sr_id,
sr_type="INCIDENT",
title=f"[민원-{cat_label}] {body.issue_description[:60]}",
description=f"민원 추적번호: {ticket_id}\n위치: {body.location or '미상'}\n\n{body.issue_description}",
status=SRStatus.RECEIVED,
priority="MEDIUM",
requested_by=f"citizen:{report.reporter_name}",
)
db.add(sr)
await db.commit()
await db.refresh(sr)
report.sr_id = sr.id
await db.commit()
sr_id_str = sr.sr_id
except Exception as exc:
logger.warning("민원 SR 자동 생성 실패 (ticket=%s): %s", ticket_id, exc)
return {
"ticket_id": ticket_id,
"status": "RECEIVED",
"status_label": "접수 완료",
"linked_sr": sr_id_str,
"message": "신고가 접수되었습니다. 추적번호로 진행 상황을 확인하실 수 있습니다.",
"track_url": f"/api/citizen/status/{ticket_id}",
}
@router.get("/status/{ticket_id}", summary="SR 진행 상황 조회 (공개)")
async def citizen_status(
ticket_id: str,
db: AsyncSession = Depends(get_db),
):
_rate_limit(f"status:{ticket_id[:12]}")
report = (await db.execute(
select(CitizenReport).where(CitizenReport.ticket_id == ticket_id)
)).scalar_one_or_none()
if not report:
raise HTTPException(404, "해당 추적번호의 신고를 찾을 수 없습니다")
return {
"ticket_id": report.ticket_id,
"category": report.category,
"location": report.location,
"status": report.status,
"status_label": _STATUS_LABEL.get(report.status, report.status),
"progress_pct": _STATUS_PROGRESS.get(report.status, 0),
"created_at": report.created_at.isoformat() if report.created_at else None,
"can_survey": report.status == "RESOLVED" and report.satisfaction_score is None,
}
@router.post("/faq-query", summary="RAG 기반 FAQ 챗봇")
async def citizen_faq_query(
body: FAQQueryIn,
db: AsyncSession = Depends(get_db),
):
_rate_limit("faq")
contexts: list[dict] = []
try:
from sqlalchemy import or_
keywords = [w for w in body.question.split() if len(w) >= 2][:5]
if keywords:
conds = [KBDocument.title.contains(kw) for kw in keywords]
rows = (await db.execute(
select(KBDocument).where(or_(*conds)).limit(3)
)).scalars().all()
for r in rows:
contexts.append({
"title": r.title,
"body": (getattr(r, "solution", None) or getattr(r, "content", None) or "")[:400],
})
except Exception:
pass
ctx_text = "\n".join(f"- {c['title']}: {c['body']}" for c in contexts) or "(관련 문서 없음)"
prompt = (
"공공기관 IT 헬프데스크 도우미입니다. 참고 문서를 바탕으로 친절하게 한국어로 답하세요.\n\n"
f"[참고 문서]\n{ctx_text}\n\n[질문] {body.question}\n\n[답변]"
)
answer = await _ollama(prompt) or "자동 답변을 생성할 수 없습니다. 신고를 접수해 주세요."
return {"question": body.question, "answer": answer[:800], "sources": [c["title"] for c in contexts]}
@router.get("/service-status", summary="공개 서비스 상태 페이지")
async def citizen_service_status(
tenant_id: Optional[str] = None,
db: AsyncSession = Depends(get_db),
):
_rate_limit("svc-status")
q = select(ServiceStatusPage)
if tenant_id:
q = q.where(ServiceStatusPage.tenant_id == tenant_id)
rows = (await db.execute(q.order_by(ServiceStatusPage.service_name))).scalars().all()
services = [
{"service_name": r.service_name, "status": r.current_status, "message": r.message}
for r in rows
]
if not services:
services = [{"service_name": "전체 시스템", "status": "operational", "message": "모든 서비스 정상 운영 중"}]
overall = "outage" if any(s["status"] == "outage" for s in services) else \
"degraded" if any(s["status"] in ("degraded", "maintenance") for s in services) else "operational"
return {"overall_status": overall, "services": services, "checked_at": datetime.now().isoformat()}
@router.post("/survey", status_code=201, summary="만족도 조사 제출")
async def citizen_survey(
body: SurveyIn,
db: AsyncSession = Depends(get_db),
):
_rate_limit(f"survey:{body.ticket_id[:12]}")
report = (await db.execute(
select(CitizenReport).where(CitizenReport.ticket_id == body.ticket_id)
)).scalar_one_or_none()
if not report:
raise HTTPException(404, "해당 추적번호의 신고를 찾을 수 없습니다")
if report.status != "RESOLVED":
raise HTTPException(400, "처리 완료된 신고에만 평가할 수 있습니다")
if report.satisfaction_score is not None:
raise HTTPException(409, "이미 평가를 제출하셨습니다")
report.satisfaction_score = body.score
db.add(SatisfactionSurvey(
citizen_report_id=report.id,
score=body.score,
comment=(body.comment or "")[:1000] or None,
))
await db.commit()
return {"message": "소중한 평가 감사합니다", "ticket_id": body.ticket_id, "score": body.score}
# ══════════════════════════════════════════════════════════════════════════════
# Internal 엔드포인트 (JWT 필요)
# ══════════════════════════════════════════════════════════════════════════════
@router.get("/survey-stats", summary="만족도 통계 (내부용)")
async def citizen_survey_stats(
days: int = 30,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
since = datetime.now() - timedelta(days=max(1, min(days, 365)))
rows = (await db.execute(
select(SatisfactionSurvey).where(SatisfactionSurvey.submitted_at >= since)
)).scalars().all()
scores = [r.score for r in rows]
total = len(scores)
avg = round(sum(scores) / total, 2) if total else None
dist = {str(n): scores.count(n) for n in range(1, 6)}
return {"period_days": days, "total_surveys": total, "avg_score": avg, "distribution": dist}
@router.post("/qr-generate", status_code=201, summary="기관별 QR 코드 생성 (내부용)")
async def citizen_qr_generate(
body: QRGenerateIn,
current_user: User = Depends(get_current_user),
):
target_url = f"/api/citizen/status?tenant_id={body.tenant_id}"
qr_payload = f"guardia-citizen://{body.tenant_id}/{body.location_code}"
return {
"tenant_id": body.tenant_id,
"location_code": body.location_code,
"location_name": body.location_name,
"qr_payload": qr_payload,
"target_url": target_url,
"note": "프론트엔드에서 qr_payload로 QR 이미지를 렌더링하세요 (외부 서비스 미사용)",
"generated_by": current_user.username,
}
@router.get("/reports", summary="접수된 민원 목록 (내부용)")
async def citizen_list_reports(
tenant_id: Optional[str] = None,
status: Optional[str] = None,
limit: int = Query(50, ge=1, le=200),
skip: int = 0,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
q = select(CitizenReport).order_by(CitizenReport.created_at.desc())
if tenant_id:
q = q.where(CitizenReport.tenant_id == tenant_id)
if status:
q = q.where(CitizenReport.status == status)
rows = (await db.execute(q.offset(skip).limit(limit))).scalars().all()
return [
{
"id": r.id,
"ticket_id": r.ticket_id,
"reporter_name": r.reporter_name,
"contact_masked": _mask_contact(r.reporter_contact),
"category": r.category,
"location": r.location,
"issue_summary": (r.issue_description or "")[:100],
"status": r.status,
"satisfaction_score": r.satisfaction_score,
"created_at": r.created_at.isoformat() if r.created_at else None,
}
for r in rows
]
@router.post("/notify-sms", summary="SR 완료 시 SMS/카카오 알림 발송 (내부용)")
async def citizen_notify_sms(
body: NotifySMSIn,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
report = (await db.execute(
select(CitizenReport).where(CitizenReport.ticket_id == body.ticket_id)
)).scalar_one_or_none()
if not report:
raise HTTPException(404, "해당 추적번호의 신고를 찾을 수 없습니다")
contact = _decrypt_contact(report.reporter_contact)
message = body.message or (
f"[GUARDiA] 신고(추적번호 {report.ticket_id})가 "
f"'{_STATUS_LABEL.get(report.status, report.status)}' 상태로 업데이트되었습니다."
)
# 카카오 알림 로그
if body.channel == "kakao" and contact:
try:
db.add(KakaoNotifyLog(recipient=contact[:50], message=message[:500], status="SENT"))
await db.commit()
except Exception as exc:
logger.warning("카카오 알림 로깅 실패: %s", exc)
return {
"ticket_id": report.ticket_id,
"channel": body.channel,
"recipient_masked": _mask_contact(report.reporter_contact),
"message": message,
"sent_by": current_user.username,
"note": "폐쇄망 환경 시뮬레이션. 실제 SMS 게이트웨이 연동 시 교체 필요.",
}