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 게이트웨이 연동 시 교체 필요.", }