first commit
This commit is contained in:
@@ -0,0 +1,861 @@
|
||||
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
|
||||
Reference in New Issue
Block a user