Files
Q-A_test/apps/secretary-api/app/services/ticket_service.py
T
SDI 12e4f17b62
CI / typecheck (push) Successful in 1m8s
CI / format (push) Failing after 1m6s
CI / lint (push) Failing after 49s
CI / test (push) Failing after 1m8s
first commit
2026-07-15 18:05:12 +09:00

862 lines
33 KiB
Python

from __future__ import annotations
from datetime import datetime, UTC
import hashlib
import json
from pathlib import Path
import re
from uuid import uuid4
from urllib import error, request
from fastapi import HTTPException, UploadFile
from sqlalchemy import select
from sqlalchemy.orm import Session, joinedload
from app.core.config import settings
from app.db.models import ABCFeedbackMapping, Attachment, RequestApproval, SupportTicket, TicketComment, Workspace
from app.schemas.ticket import (
SupportAttachmentRecord,
SupportTicketCommentRecord,
SupportActivityItem,
SupportTicketRecord,
TicketCommentCreateRequest,
TicketCommentUpdateRequest,
TicketCreateRequest,
TicketCreateResponse,
TicketMutationResponse,
WorkspaceFormField,
WorkspaceFormTemplateResponse,
WorkspaceSummary,
)
class TicketService:
_workspace_targets = {
"INTRA_BOOK_REQUEST": {
"project_id": 2,
"channel_id": 2,
"project_name": "project-2",
"channel_name": "channel-2",
},
"INTRA_GENERAL_QNA": {
"project_id": 1,
"channel_id": 1,
"project_name": "CRUD Check Project",
"channel_name": "CRUD Channel",
},
}
def list_workspaces(self, db: Session) -> list[WorkspaceSummary]:
workspaces = db.execute(
select(Workspace).where(Workspace.is_active.is_(True)).order_by(Workspace.id.asc()),
).scalars().all()
return [
WorkspaceSummary(
workspace_code=workspace.workspace_code,
workspace_name=workspace.workspace_name,
workspace_type=workspace.workspace_type,
)
for workspace in workspaces
]
def get_workspace_form_template(self, db: Session, workspace_code: str) -> WorkspaceFormTemplateResponse:
workspace = self._get_workspace_by_code(db, workspace_code, raise_not_found=False)
templates = {
"INTRA_BOOK_REQUEST": WorkspaceFormTemplateResponse(
workspace_code="INTRA_BOOK_REQUEST",
workspace_name=workspace.workspace_name if workspace else "인트라넷 도서 신청",
requires_approval=True,
fields=[
WorkspaceFormField(field_code="title", label="제목", field_type="text", required=True),
WorkspaceFormField(field_code="description", label="내용", field_type="textarea", required=True),
WorkspaceFormField(field_code="requester_contact", label="연락처", field_type="text", required=True),
WorkspaceFormField(field_code="book_title", label="도서명", field_type="text", required=True),
WorkspaceFormField(field_code="author", label="저자", field_type="text"),
WorkspaceFormField(field_code="purchase_reason", label="구매 사유", field_type="textarea", required=True),
],
),
"INTRA_GENERAL_QNA": WorkspaceFormTemplateResponse(
workspace_code="INTRA_GENERAL_QNA",
workspace_name=workspace.workspace_name if workspace else "인트라넷 일반 문의",
requires_approval=False,
fields=[
WorkspaceFormField(field_code="title", label="제목", field_type="text", required=True),
WorkspaceFormField(field_code="description", label="내용", field_type="textarea", required=True),
WorkspaceFormField(field_code="requester_contact", label="연락처", field_type="text", required=True),
WorkspaceFormField(field_code="environment", label="환경", field_type="text"),
WorkspaceFormField(field_code="error_message", label="오류 메시지", field_type="textarea"),
WorkspaceFormField(field_code="expected_result", label="기대 결과", field_type="textarea"),
],
),
}
return templates.get(
workspace_code,
WorkspaceFormTemplateResponse(
workspace_code=workspace_code,
workspace_name=workspace.workspace_name if workspace else "미등록 워크스페이스",
requires_approval=False,
fields=[
WorkspaceFormField(field_code="title", label="제목", field_type="text", required=True),
WorkspaceFormField(field_code="description", label="내용", field_type="textarea", required=True),
],
),
)
def create_ticket(
self,
db: Session,
payload: TicketCreateRequest,
attachments: list[UploadFile] | None = None,
) -> TicketCreateResponse:
workspace = self._get_workspace_by_code(db, payload.workspace_code)
approval_status = "PENDING" if payload.requires_approval else "NOT_REQUIRED"
status_code = "PENDING_APPROVAL" if payload.requires_approval else "RECEIVED"
issue_link_status = "NOT_REQUIRED" if payload.requires_approval else "NOT_REQUIRED"
ticket = SupportTicket(
workspace_id=workspace.id,
requester_id=payload.requester_id,
requester_tenant_id=payload.requester_tenant_id,
requester_contact=payload.requester_contact or "",
ticket_type=payload.ticket_type,
title=payload.title,
description=payload.description,
category_code=self._resolve_category_code(
payload.workspace_code,
payload.category_code,
),
status_code=status_code,
approval_status=approval_status,
sync_status="PENDING",
issue_link_status=issue_link_status,
requires_approval=payload.requires_approval,
extra_fields=payload.extra_fields,
)
db.add(ticket)
db.flush()
abc_feedback_id = self._create_abc_feedback(payload)
ticket.sync_status = "SYNCED"
target = self._resolve_workspace_target(payload.workspace_code)
mapping = ABCFeedbackMapping(
ticket_id=ticket.id,
workspace_id=workspace.id,
abc_channel_id=str(target["channel_id"]),
abc_feedback_id=str(abc_feedback_id),
abc_feedback_url=(
f"/main/project/{target['project_id']}/feedback?channelId={target['channel_id']}"
),
sync_status="SYNCED",
)
db.add(mapping)
if attachments:
self._save_ticket_attachments(db, workspace, ticket, payload, attachments)
db.commit()
db.refresh(ticket)
return TicketCreateResponse(
ticket_id=ticket.id,
workspace_code=payload.workspace_code,
status_code=ticket.status_code,
sync_status=ticket.sync_status,
message="ABC 피드백과 내부 티켓이 함께 저장되었습니다."
)
def _create_abc_feedback(self, payload: TicketCreateRequest) -> int:
if not settings.abc_api_key:
raise HTTPException(
status_code=500,
detail="ABC API key is not configured.",
)
target = self._resolve_workspace_target(payload.workspace_code)
endpoint = (
f"{settings.abc_api_base_url}/api/projects/"
f"{target['project_id']}/channels/{target['channel_id']}/feedbacks"
)
body = json.dumps(
{
"title": payload.title,
"contents": payload.description,
}
).encode("utf-8")
req = request.Request(
endpoint,
data=body,
headers={
"Content-Type": "application/json",
"x-api-key": settings.abc_api_key,
},
method="POST",
)
try:
with request.urlopen(req, timeout=10) as response:
payload_data = json.loads(response.read().decode("utf-8"))
except error.HTTPError as exc:
detail = exc.read().decode("utf-8", errors="replace")
raise HTTPException(
status_code=502,
detail=f"ABC feedback creation failed: {detail}",
) from exc
except error.URLError as exc:
raise HTTPException(
status_code=502,
detail=f"ABC API connection failed: {exc.reason}",
) from exc
feedback_id = payload_data.get("id")
if not isinstance(feedback_id, int):
raise HTTPException(
status_code=502,
detail="ABC API did not return a feedback id.",
)
return feedback_id
def _save_ticket_attachments(
self,
db: Session,
workspace: Workspace,
ticket: SupportTicket,
payload: TicketCreateRequest,
attachments: list[UploadFile],
) -> None:
target = self._resolve_workspace_target(payload.workspace_code)
upload_root = Path(settings.upload_root_dir)
relative_directory = self._build_attachment_directory(target)
absolute_directory = upload_root / relative_directory
absolute_directory.mkdir(parents=True, exist_ok=True)
saved_file_paths: list[Path] = []
try:
for attachment in attachments:
original_file_name = attachment.filename or "attachment.bin"
file_bytes = attachment.file.read()
if len(file_bytes) > settings.upload_max_file_size_mb * 1024 * 1024:
raise HTTPException(
status_code=413,
detail=f"첨부파일은 최대 {settings.upload_max_file_size_mb}MB까지 업로드할 수 있습니다.",
)
sanitized_name = self._sanitize_file_name(original_file_name)
file_extension = Path(sanitized_name).suffix
stored_file_name = f"{uuid4().hex}{file_extension}"
absolute_path = absolute_directory / stored_file_name
absolute_path.write_bytes(file_bytes)
saved_file_paths.append(absolute_path)
db.add(
Attachment(
ticket_id=ticket.id,
workspace_id=workspace.id,
uploader_id=payload.requester_id,
uploader_tenant_id=payload.requester_tenant_id,
original_file_name=original_file_name,
stored_file_name=stored_file_name,
storage_provider="LOCAL",
storage_bucket=str(upload_root),
storage_key=str(relative_directory / stored_file_name),
mime_type=attachment.content_type,
file_extension=file_extension.lstrip(".") or None,
file_size=len(file_bytes),
checksum_sha256=hashlib.sha256(file_bytes).hexdigest(),
)
)
except Exception:
for saved_file_path in saved_file_paths:
if saved_file_path.exists():
saved_file_path.unlink()
raise
def _build_attachment_directory(self, target: dict[str, int | str]) -> Path:
project_id = target["project_id"]
channel_id = target["channel_id"]
project_name = self._sanitize_path_segment(
str(target.get("project_name") or f"project-{project_id}")
)
channel_name = self._sanitize_path_segment(
str(target.get("channel_name") or f"channel-{channel_id}")
)
return Path(project_name) / channel_name
def list_workspace_tickets(
self,
db: Session,
workspace_code: str,
requester_id: str | None,
requester_tenant_id: str | None,
) -> list[SupportTicketRecord]:
statement = (
select(SupportTicket)
.join(Workspace)
.options(joinedload(SupportTicket.workspace), joinedload(SupportTicket.feedback_mapping), joinedload(SupportTicket.attachments))
.where(Workspace.workspace_code == workspace_code)
.order_by(SupportTicket.id.desc())
)
if requester_id:
statement = statement.where(SupportTicket.requester_id == requester_id)
if requester_tenant_id:
statement = statement.where(SupportTicket.requester_tenant_id == requester_tenant_id)
tickets = db.execute(statement).unique().scalars().all()
return [self._serialize_ticket(ticket) for ticket in tickets]
def list_tickets(self, db: Session) -> list[SupportTicketRecord]:
tickets = db.execute(
select(SupportTicket)
.options(joinedload(SupportTicket.workspace), joinedload(SupportTicket.feedback_mapping), joinedload(SupportTicket.approvals), joinedload(SupportTicket.attachments))
.order_by(SupportTicket.id.desc()),
).unique().scalars().all()
return [self._serialize_ticket(ticket) for ticket in tickets]
def get_ticket(
self,
db: Session,
ticket_id: int,
workspace_code: str | None = None,
) -> SupportTicketRecord:
ticket = self._resolve_ticket_for_external_id(db, ticket_id, workspace_code)
if not ticket:
raise HTTPException(status_code=404, detail="지원 요청을 찾을 수 없습니다.")
return self._serialize_ticket(ticket)
def list_ticket_comments(
self,
db: Session,
ticket_id: int,
workspace_code: str | None = None,
) -> list[SupportTicketCommentRecord]:
ticket = self._resolve_ticket_for_external_id(db, ticket_id, workspace_code)
if not ticket:
return []
comments = db.execute(
select(TicketComment)
.where(TicketComment.ticket_id == ticket.id, TicketComment.deleted_at.is_(None))
.order_by(TicketComment.created_at.asc(), TicketComment.id.asc()),
).scalars().all()
return [self._serialize_comment(comment) for comment in comments]
def create_ticket_comment(
self,
db: Session,
ticket_id: int,
payload: TicketCommentCreateRequest,
workspace_code: str | None = None,
) -> SupportTicketCommentRecord:
ticket = self._resolve_ticket_for_external_id(
db,
ticket_id,
workspace_code,
requester_id=payload.requester_id,
requester_tenant_id=payload.requester_tenant_id,
)
if not ticket:
raise HTTPException(status_code=404, detail="지원 요청을 찾을 수 없습니다.")
comment = TicketComment(
ticket_id=ticket.id,
author_id=payload.requester_id,
author_tenant_id=payload.requester_tenant_id,
author_name=payload.author_name or payload.requester_id,
content=payload.content.strip(),
is_internal=payload.is_internal,
sync_status="LOCAL_ONLY",
)
db.add(comment)
db.commit()
db.refresh(comment)
return self._serialize_comment(comment)
def update_ticket_comment(
self,
db: Session,
ticket_id: int,
comment_id: int,
payload: TicketCommentUpdateRequest,
) -> SupportTicketCommentRecord:
comment = self._get_comment(db, ticket_id, comment_id)
self._assert_comment_author(comment, payload.requester_id, payload.requester_tenant_id)
comment.content = payload.content.strip()
comment.edited_at = datetime.now(UTC)
db.commit()
db.refresh(comment)
return self._serialize_comment(comment)
def delete_ticket_comment(
self,
db: Session,
ticket_id: int,
comment_id: int,
requester_id: str,
requester_tenant_id: str,
) -> dict[str, int | bool]:
comment = self._get_comment(db, ticket_id, comment_id)
self._assert_comment_author(comment, requester_id, requester_tenant_id)
comment.deleted_at = datetime.now(UTC)
db.commit()
return {
"deleted": True,
"ticket_id": ticket_id,
"comment_id": comment_id,
}
def approve_ticket(self, db: Session, ticket_id: int) -> TicketMutationResponse:
ticket = self._get_ticket(db, ticket_id)
ticket.approval_status = "APPROVED"
ticket.status_code = "APPROVED"
ticket.sync_status = ticket.sync_status or "PENDING"
db.add(
RequestApproval(
ticket_id=ticket.id,
approver_id="ops-demo",
approver_tenant_id="baron",
approval_status="APPROVED",
comment="운영 승인 큐에서 승인 처리되었습니다.",
approved_at=datetime.now(UTC),
),
)
db.commit()
db.refresh(ticket)
return self._serialize_mutation(ticket)
def create_issue(self, db: Session, ticket_id: int) -> TicketMutationResponse:
ticket = self._get_ticket(db, ticket_id)
ticket.issue_link_status = "LINKED"
ticket.status_code = "IN_PROGRESS"
ticket.sync_status = "SYNCED"
if ticket.feedback_mapping:
ticket.feedback_mapping.sync_status = "SYNCED"
ticket.feedback_mapping.abc_feedback_url = f"ABC-{ticket.id}"
db.commit()
db.refresh(ticket)
return self._serialize_mutation(ticket)
def sync_issue_link_status(
self,
db: Session,
ticket_id: int,
linked: bool,
workspace_code: str | None = None,
) -> dict[str, int | str | bool]:
ticket = self._resolve_ticket_for_external_id(db, ticket_id, workspace_code)
if not ticket:
raise HTTPException(status_code=404, detail="지원 요청을 찾을 수 없습니다.")
ticket.issue_link_status = "LINKED" if linked else "NOT_LINKED"
if linked:
ticket.status_code = "IN_PROGRESS"
ticket.sync_status = "SYNCED"
elif ticket.status_code == "IN_PROGRESS":
ticket.status_code = "RECEIVED"
db.commit()
db.refresh(ticket)
return {
"ticket_id": ticket.id,
"issue_link_status": ticket.issue_link_status,
"status_code": ticket.status_code,
"linked": linked,
}
def _serialize_ticket(self, ticket: SupportTicket) -> SupportTicketRecord:
activity = self._build_activity(ticket)
extra_fields = dict(ticket.extra_fields or {})
attachments = [
self._serialize_attachment(attachment)
for attachment in ticket.attachments
if attachment.deleted_at is None and attachment.attachment_status == "ACTIVE"
]
if ticket.feedback_mapping:
extra_fields.setdefault(
"abc_feedback_id",
ticket.feedback_mapping.abc_feedback_id,
)
if ticket.feedback_mapping.abc_feedback_url:
extra_fields.setdefault(
"abc_feedback_url",
ticket.feedback_mapping.abc_feedback_url,
)
return SupportTicketRecord(
ticket_id=ticket.id,
workspace_code=ticket.workspace.workspace_code,
workspace_name=ticket.workspace.workspace_name,
title=ticket.title,
description=ticket.description,
requester_id=ticket.requester_id,
requester_tenant_id=ticket.requester_tenant_id,
requester_contact=ticket.requester_contact or "",
category_code=ticket.category_code or "GENERAL",
ticket_type=ticket.ticket_type,
requires_approval=ticket.requires_approval,
status_code=ticket.status_code,
approval_status=ticket.approval_status,
sync_status=ticket.sync_status,
issue_link_status=ticket.issue_link_status,
created_at=ticket.created_at.isoformat(),
updated_at=ticket.updated_at.isoformat(),
extra_fields=extra_fields,
attachments=attachments,
activity=activity,
)
def _serialize_attachment(self, attachment: Attachment) -> SupportAttachmentRecord:
return SupportAttachmentRecord(
attachment_id=attachment.id,
original_file_name=attachment.original_file_name,
mime_type=attachment.mime_type,
file_size=attachment.file_size,
created_at=attachment.created_at.isoformat(),
)
def _serialize_mutation(self, ticket: SupportTicket) -> TicketMutationResponse:
return TicketMutationResponse(
ticket_id=ticket.id,
status_code=ticket.status_code,
approval_status=ticket.approval_status,
issue_link_status=ticket.issue_link_status,
updated_at=ticket.updated_at.isoformat(),
)
def _serialize_comment(self, comment: TicketComment) -> SupportTicketCommentRecord:
return SupportTicketCommentRecord(
comment_id=comment.id,
ticket_id=comment.ticket_id,
author_id=comment.author_id,
author_tenant_id=comment.author_tenant_id,
author_name=comment.author_name or comment.author_id,
content=comment.content,
is_internal=comment.is_internal,
created_at=comment.created_at.isoformat(),
updated_at=comment.updated_at.isoformat(),
edited_at=comment.edited_at.isoformat() if comment.edited_at else None,
)
def _build_activity(self, ticket: SupportTicket) -> list[SupportActivityItem]:
items = [
SupportActivityItem(
id=f"received-{ticket.id}",
label="접수",
detail="사용자가 지원 요청을 등록했습니다.",
timestamp=ticket.created_at.isoformat(),
),
]
if ticket.approval_status == "PENDING":
items.insert(
0,
SupportActivityItem(
id=f"approval-pending-{ticket.id}",
label="승인 대기",
detail="승인형 요청으로 분류되어 승인 큐에 등록되었습니다.",
timestamp=ticket.updated_at.isoformat(),
),
)
if ticket.approval_status == "APPROVED":
items.insert(
0,
SupportActivityItem(
id=f"approved-{ticket.id}",
label="승인 완료",
detail="운영 승인 큐에서 승인 처리되었습니다.",
timestamp=ticket.updated_at.isoformat(),
),
)
if ticket.issue_link_status == "LINKED":
items.insert(
0,
SupportActivityItem(
id=f"linked-{ticket.id}",
label="이슈 연결",
detail="관리자 화면에서 이슈 연결이 완료되었습니다.",
timestamp=ticket.updated_at.isoformat(),
),
)
return items
def _get_workspace_by_code(self, db: Session, workspace_code: str, raise_not_found: bool = True) -> Workspace | None:
workspace = db.execute(
select(Workspace).where(Workspace.workspace_code == workspace_code),
).scalar_one_or_none()
if not workspace and raise_not_found:
raise HTTPException(status_code=404, detail="워크스페이스를 찾을 수 없습니다.")
return workspace
def _resolve_workspace_target(self, workspace_code: str) -> dict[str, int | str]:
return self._workspace_targets.get(
workspace_code,
{
"project_id": settings.abc_project_id,
"channel_id": settings.abc_channel_id,
"project_name": f"project-{settings.abc_project_id}",
"channel_name": f"channel-{settings.abc_channel_id}",
},
)
def _sanitize_path_segment(self, value: str) -> str:
sanitized = re.sub(r'[\\/:*?"<>|]+', '_', value).strip()
sanitized = re.sub(r'\s+', ' ', sanitized)
return sanitized or 'unnamed'
def _sanitize_file_name(self, file_name: str) -> str:
base_name = Path(file_name).name.strip() or "attachment.bin"
sanitized = re.sub(r"[^A-Za-z0-9._-]+", "_", base_name)
return sanitized or "attachment.bin"
def _get_ticket(self, db: Session, ticket_id: int) -> SupportTicket:
ticket = db.execute(
select(SupportTicket)
.options(joinedload(SupportTicket.workspace), joinedload(SupportTicket.feedback_mapping), joinedload(SupportTicket.approvals), joinedload(SupportTicket.attachments))
.where(SupportTicket.id == ticket_id),
).unique().scalar_one_or_none()
if not ticket:
raise HTTPException(status_code=404, detail="지원 요청을 찾을 수 없습니다.")
return ticket
def _get_comment(self, db: Session, ticket_id: int, comment_id: int) -> TicketComment:
ticket = self._resolve_ticket_for_external_id(db, ticket_id)
if not ticket:
raise HTTPException(status_code=404, detail="댓글을 찾을 수 없습니다.")
comment = db.execute(
select(TicketComment).where(
TicketComment.id == comment_id,
TicketComment.ticket_id == ticket.id,
TicketComment.deleted_at.is_(None),
),
).scalar_one_or_none()
if not comment:
raise HTTPException(status_code=404, detail="댓글을 찾을 수 없습니다.")
return comment
def _resolve_ticket_for_external_id(
self,
db: Session,
external_ticket_id: int,
workspace_code: str | None = None,
requester_id: str | None = None,
requester_tenant_id: str | None = None,
) -> SupportTicket | None:
direct_ticket = db.execute(
select(SupportTicket)
.options(joinedload(SupportTicket.workspace), joinedload(SupportTicket.feedback_mapping), joinedload(SupportTicket.approvals), joinedload(SupportTicket.attachments))
.where(SupportTicket.id == external_ticket_id),
).unique().scalar_one_or_none()
if direct_ticket:
return direct_ticket
mapped_ticket = db.execute(
select(SupportTicket)
.join(ABCFeedbackMapping, ABCFeedbackMapping.ticket_id == SupportTicket.id)
.options(joinedload(SupportTicket.workspace), joinedload(SupportTicket.feedback_mapping), joinedload(SupportTicket.approvals), joinedload(SupportTicket.attachments))
.where(ABCFeedbackMapping.abc_feedback_id == str(external_ticket_id)),
).unique().scalar_one_or_none()
if mapped_ticket:
return mapped_ticket
if not workspace_code:
return None
return self._provision_ticket_from_abc_feedback(
db,
external_ticket_id,
workspace_code,
requester_id,
requester_tenant_id,
)
def _provision_ticket_from_abc_feedback(
self,
db: Session,
abc_feedback_id: int,
workspace_code: str,
requester_id: str | None,
requester_tenant_id: str | None,
) -> SupportTicket | None:
workspace = self._get_workspace_by_code(db, workspace_code, raise_not_found=False)
if not workspace:
return None
feedback = self._fetch_abc_feedback(abc_feedback_id)
title = feedback.get("title") if isinstance(feedback.get("title"), str) else f"ABC Feedback {abc_feedback_id}"
description = (
feedback.get("contents") if isinstance(feedback.get("contents"), str)
else feedback.get("message") if isinstance(feedback.get("message"), str)
else ""
)
ticket = SupportTicket(
workspace_id=workspace.id,
requester_id=requester_id or "legacy-abc-user",
requester_tenant_id=requester_tenant_id or "baron",
requester_contact="",
ticket_type="GENERAL",
source_system="ABC",
title=title,
description=description,
category_code=self._default_category_code(workspace_code),
status_code="RECEIVED",
approval_status="NOT_REQUIRED",
sync_status="SYNCED",
issue_link_status="NOT_LINKED",
requires_approval=False,
extra_fields={
"abc_feedback_id": str(abc_feedback_id),
},
)
db.add(ticket)
db.flush()
mapping = ABCFeedbackMapping(
ticket_id=ticket.id,
workspace_id=workspace.id,
abc_channel_id=str(settings.abc_channel_id),
abc_feedback_id=str(abc_feedback_id),
abc_feedback_url=(
f"/main/project/{settings.abc_project_id}/feedback?channelId={settings.abc_channel_id}"
),
sync_status="SYNCED",
)
db.add(mapping)
db.commit()
db.refresh(ticket)
return ticket
def _fetch_abc_feedback(self, feedback_id: int) -> dict[str, object]:
if not settings.abc_api_key:
raise HTTPException(status_code=500, detail="ABC API key is not configured.")
endpoint = (
f"{settings.abc_api_base_url}/api/projects/"
f"{settings.abc_project_id}/channels/{settings.abc_channel_id}/feedbacks/{feedback_id}"
)
req = request.Request(
endpoint,
headers={
"x-api-key": settings.abc_api_key,
},
method="GET",
)
try:
with request.urlopen(req, timeout=10) as response:
payload_data = json.loads(response.read().decode("utf-8"))
except error.HTTPError as exc:
detail = exc.read().decode("utf-8", errors="replace")
raise HTTPException(status_code=502, detail=f"ABC feedback fetch failed: {detail}") from exc
except error.URLError as exc:
raise HTTPException(status_code=502, detail=f"ABC API connection failed: {exc.reason}") from exc
if not isinstance(payload_data, dict):
raise HTTPException(status_code=502, detail="ABC API returned an invalid feedback payload.")
return payload_data
def get_ticket_attachment(
self,
db: Session,
ticket_id: int,
attachment_id: int,
workspace_code: str | None = None,
) -> Attachment:
ticket = self._resolve_ticket_for_external_id(db, ticket_id, workspace_code)
if not ticket:
raise HTTPException(status_code=404, detail="지원 요청을 찾을 수 없습니다.")
attachment = db.execute(
select(Attachment).where(
Attachment.id == attachment_id,
Attachment.ticket_id == ticket.id,
Attachment.deleted_at.is_(None),
Attachment.attachment_status == "ACTIVE",
)
).scalar_one_or_none()
if not attachment:
raise HTTPException(status_code=404, detail="첨부파일을 찾을 수 없습니다.")
return attachment
def resolve_attachment_path(self, attachment: Attachment) -> Path:
storage_bucket = attachment.storage_bucket or settings.upload_root_dir
file_path = Path(storage_bucket) / attachment.storage_key
if not file_path.exists():
raise HTTPException(status_code=404, detail="첨부파일을 찾을 수 없습니다.")
return file_path
def _assert_comment_author(
self,
comment: TicketComment,
requester_id: str,
requester_tenant_id: str,
) -> None:
if (
comment.author_id != requester_id
or comment.author_tenant_id != requester_tenant_id
):
raise HTTPException(status_code=403, detail="본인이 작성한 댓글만 수정 또는 삭제할 수 있습니다.")
def _default_category_code(self, workspace_code: str) -> str:
defaults = {
"INTRA_BOOK_REQUEST": "BOOK",
"INTRA_GENERAL_QNA": "GENERAL_QNA",
"INTRA_SUPPLIES_REQUEST": "SUPPLIES",
"INTRA_VEHICLE_REQUEST": "VEHICLE",
"INTRA_EQUIPMENT_RENTAL": "EQUIPMENT",
}
return defaults.get(workspace_code, "GENERAL_QNA")
def _resolve_category_code(
self,
workspace_code: str,
category_code: str | None,
) -> str:
normalized = (category_code or "").strip().upper()
if not normalized or normalized == "GENERAL":
return self._default_category_code(workspace_code)
return normalized