862 lines
33 KiB
Python
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
|