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