Files
egbim_qa_platform/apps/secretary-api/app/services/ticket_service.py
T
root aaddfc7bfc
Deploy feedback demo / deploy (push) Failing after 4m26s
API 적용 최초 배포
2026-09-01 17:12:46 +09:00

1942 lines
72 KiB
Python

from __future__ import annotations
from datetime import datetime
import hashlib
import json
from pathlib import Path
import re
from uuid import uuid4
from urllib import error, request
from zoneinfo import ZoneInfo
from fastapi import HTTPException, UploadFile
from sqlalchemy import and_, or_, select, text as sql_text
from sqlalchemy.orm import Session, joinedload, selectinload
from app.core.config import ABCProjectTarget, settings
from app.db.models import ABCFeedbackMapping, Attachment, RequestApproval, SupportRoleAssignment, SupportTicket, SupportUser, TicketComment, Workspace, WorkspaceChannelMapping
from app.services.access_service import AccessService
from app.services.workspace_mapping_service import workspace_mapping_service
from app.services.storage_service import storage_service
from app.schemas.ticket import (
SupportAttachmentRecord,
SupportTicketCommentRecord,
SupportActivityItem,
SupportTicketRecord,
TicketCommentCreateRequest,
TicketCommentUpdateRequest,
TicketCreateRequest,
TicketCreateResponse,
TicketAssigneeCandidate,
TicketAssigneeUpdateRequest,
TicketUpdateRequest,
TicketMutationResponse,
WorkspaceFormField,
WorkspaceFormTemplateResponse,
WorkspaceSummary,
)
SEOUL_TIMEZONE = ZoneInfo("Asia/Seoul")
QNA_CATEGORY_CODES = {"ERROR_QNA", "IMPROVEMENT_QNA", "GENERAL_QNA"}
QNA_CATEGORY_OPTIONS = [
{"id": 1, "key": "ERROR_QNA", "name": "오류 문의"},
{"id": 2, "key": "IMPROVEMENT_QNA", "name": "개선 문의"},
{"id": 3, "key": "GENERAL_QNA", "name": "일반 문의"},
]
access_service = AccessService()
def serialize_support_timestamp(value: datetime | None) -> str | None:
"""Serialize DB timestamps with an explicit Asia/Seoul offset.
MySQL timestamps are created/read in Asia/Seoul in staging. SQLAlchemy
returns those values as naive datetimes, so treat a naive value as Seoul
time before serializing it. Including the offset prevents the browser from
interpreting the API response in its own local timezone.
"""
if value is None:
return None
if value.tzinfo is None:
value = value.replace(tzinfo=SEOUL_TIMEZONE)
return value.astimezone(SEOUL_TIMEZONE).isoformat()
def now_support_timestamp() -> datetime:
"""Return a naive Asia/Seoul timestamp for MySQL TIMESTAMP columns."""
return datetime.now(SEOUL_TIMEZONE).replace(tzinfo=None)
class TicketService:
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)
qna_fields = [
WorkspaceFormField(
field_code="category",
label="구분",
field_type="select",
required=True,
options=QNA_CATEGORY_OPTIONS,
),
WorkspaceFormField(field_code="title", label="제목", field_type="text", required=True),
WorkspaceFormField(field_code="description", label="내용", field_type="textarea", required=True),
WorkspaceFormField(field_code="ip_address", label="사용자 IP 주소", field_type="text"),
WorkspaceFormField(field_code="mac_address", label="MAC 주소", field_type="text"),
]
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),
],
),
"Q&A_Platform": WorkspaceFormTemplateResponse(
workspace_code="Q&A_Platform",
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="ip_address", label="사용자 IP 주소", field_type="text"),
WorkspaceFormField(field_code="mac_address", label="MAC 주소", field_type="text"),
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"),
],
),
"EGBIM_DEMO": WorkspaceFormTemplateResponse(
workspace_code="EGBIM_DEMO",
workspace_name=workspace.workspace_name if workspace else "EGBIM_DEMO",
requires_approval=False,
fields=qna_fields,
),
}
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 "",
requester_email=payload.requester_email,
requester_name=payload.requester_name,
requester_department=payload.requester_department,
requester_phone_number=payload.requester_phone_number,
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,
feedback_status="INIT",
approval_status=approval_status,
sync_status="PENDING",
issue_link_status=issue_link_status,
is_secret=payload.is_secret,
requires_approval=payload.requires_approval,
extra_fields=payload.extra_fields,
)
db.add(ticket)
db.flush()
abc_feedback_id = self._create_abc_feedback(db, payload)
ticket.sync_status = "SYNCED"
target = self._resolve_workspace_target(db, 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,
feedback_status=ticket.feedback_status,
sync_status=ticket.sync_status,
message="ABC 피드백과 내부 티켓이 함께 저장되었습니다."
)
def _create_abc_feedback(
self,
db: Session,
payload: TicketCreateRequest,
) -> int:
target = self._resolve_workspace_target(db, payload.workspace_code)
api_key = self._resolve_api_key(target)
if not api_key:
raise HTTPException(
status_code=500,
detail="ABC API key is not configured.",
)
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": 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 _update_abc_feedback(
self,
db: Session,
workspace_code: str,
feedback_id: int,
body: dict[str, str],
) -> None:
target = self._resolve_workspace_target(db, workspace_code)
api_key = self._resolve_api_key(target)
if not api_key:
raise HTTPException(status_code=500, detail="ABC API key is not configured.")
endpoint = (
f"{settings.abc_api_base_url}/api/projects/"
f"{target.project_id}/channels/{target.channel_id}/feedbacks/{feedback_id}"
)
req = request.Request(
endpoint,
data=json.dumps(body).encode("utf-8"),
headers={"Content-Type": "application/json", "x-api-key": api_key},
method="PUT",
)
try:
with request.urlopen(req, timeout=10):
return
except error.HTTPError as exc:
detail = exc.read().decode("utf-8", errors="replace")
raise HTTPException(
status_code=502,
detail=f"ABC feedback update failed: {detail}",
) from exc
except error.URLError as exc:
raise HTTPException(
status_code=502,
detail=f"ABC API connection failed: {exc.reason}",
) from exc
def _delete_abc_feedback(
self,
db: Session,
workspace_code: str,
feedback_id: int,
) -> None:
target = self._resolve_workspace_target(db, workspace_code)
api_key = self._resolve_api_key(target)
if not api_key:
raise HTTPException(status_code=500, detail="ABC API key is not configured.")
endpoint = (
f"{settings.abc_api_base_url}/api/projects/"
f"{target.project_id}/channels/{target.channel_id}/feedbacks"
)
req = request.Request(
endpoint,
data=json.dumps({"feedbackIds": [feedback_id]}).encode("utf-8"),
headers={"Content-Type": "application/json", "x-api-key": api_key},
method="DELETE",
)
try:
with request.urlopen(req, timeout=10):
return
except error.HTTPError as exc:
detail = exc.read().decode("utf-8", errors="replace")
raise HTTPException(
status_code=502,
detail=f"ABC feedback deletion failed: {detail}",
) from exc
except error.URLError as exc:
raise HTTPException(
status_code=502,
detail=f"ABC API connection failed: {exc.reason}",
) from exc
def _save_ticket_attachments(
self,
db: Session,
workspace: Workspace,
ticket: SupportTicket,
payload: TicketCreateRequest,
attachments: list[UploadFile],
) -> None:
self._save_attachments(
db,
workspace,
ticket,
payload.requester_id,
payload.requester_tenant_id,
attachments,
)
def _save_attachments(
self,
db: Session,
workspace: Workspace,
ticket: SupportTicket,
uploader_id: str,
uploader_tenant_id: str,
attachments: list[UploadFile],
comment_id: int | None = None,
) -> None:
target = self._resolve_workspace_target(db, workspace.workspace_code)
upload_root = Path(settings.upload_root_dir)
relative_directory = self._build_attachment_directory(target)
absolute_directory = upload_root / relative_directory
if not storage_service.enabled:
absolute_directory.mkdir(parents=True, exist_ok=True)
saved_file_paths: list[Path] = []
uploaded_storage_keys: list[str] = []
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}"
storage_key = str(
Path("support")
/ self._sanitize_path_segment(workspace.workspace_code)
/ str(target.project_id)
/ str(target.channel_id)
/ str(ticket.id)
/ (f"comment-{comment_id}" if comment_id is not None else "feedback")
/ stored_file_name
)
if storage_service.enabled:
try:
storage_service.put_bytes(
storage_key,
file_bytes,
attachment.content_type,
)
except Exception as exc:
raise HTTPException(
status_code=502,
detail="R2 첨부파일 저장에 실패했습니다.",
) from exc
uploaded_storage_keys.append(storage_key)
storage_provider = "R2"
storage_bucket = settings.r2_bucket
else:
absolute_path = absolute_directory / stored_file_name
absolute_path.write_bytes(file_bytes)
saved_file_paths.append(absolute_path)
storage_provider = "LOCAL"
storage_bucket = str(upload_root)
db.add(
Attachment(
ticket_id=ticket.id,
comment_id=comment_id,
workspace_id=workspace.id,
uploader_id=uploader_id,
uploader_tenant_id=uploader_tenant_id,
original_file_name=original_file_name,
stored_file_name=stored_file_name,
storage_provider=storage_provider,
storage_bucket=storage_bucket,
storage_key=(
storage_key
if storage_service.enabled
else 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()
for storage_key in uploaded_storage_keys:
try:
storage_service.delete(storage_key)
except Exception:
pass
raise
def _build_attachment_directory(self, target: ABCProjectTarget) -> Path:
project_id = target.project_id
channel_id = target.channel_id
project_name = self._sanitize_path_segment(
str(target.project_name or f"project-{project_id}")
)
channel_name = self._sanitize_path_segment(
str(target.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,
SupportTicket.sync_status != "DELETED",
)
.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, db) for ticket in tickets]
FEEDBACK_STATUSES = {"INIT", "ON_REVIEW", "DETAILED_REVIEW", "IN_PROGRESS", "RESOLVED", "PENDING"}
def update_feedback_status(
self,
db: Session,
ticket_id: int,
feedback_status: str,
workspace_code: str | None = None,
abc_feedback_id: int | None = None,
) -> TicketMutationResponse:
if feedback_status not in self.FEEDBACK_STATUSES:
raise HTTPException(
status_code=400,
detail=f"지원하지 않는 피드백 처리 상태입니다: {feedback_status}",
)
ticket = self._resolve_ticket_for_external_id(
db,
ticket_id,
workspace_code,
abc_feedback_id=abc_feedback_id,
)
if ticket is None:
raise HTTPException(status_code=404, detail="지원 요청을 찾을 수 없습니다.")
# This field belongs to the feedback lifecycle. It deliberately does
# not change status_code or issue_link_status.
ticket.feedback_status = feedback_status
db.commit()
db.refresh(ticket)
return self._serialize_mutation(ticket)
def list_feedback_statuses(
self,
db: Session,
principal,
project_id: int,
channel_id: int | None = None,
) -> list[dict[str, int | str]]:
mappings = db.execute(
select(WorkspaceChannelMapping, Workspace)
.join(Workspace, Workspace.id == WorkspaceChannelMapping.workspace_id)
.where(
WorkspaceChannelMapping.abc_project_id == project_id,
WorkspaceChannelMapping.is_active.is_(True),
Workspace.is_active.is_(True),
)
).all()
allowed_channels_by_workspace: dict[int, set[str]] = {}
for mapping, workspace in mappings:
if channel_id is not None and str(mapping.abc_channel_id) != str(channel_id):
continue
access = access_service.find_workspace_access(
db,
principal,
workspace.workspace_code,
)
if access is not None and (
access.can_manage
or access.workspace_role in {"SYSTEM_ADMIN", "SUPER_ADMIN", "PROJECT_MANAGER"}
):
allowed_channels_by_workspace.setdefault(workspace.id, set()).add(
str(mapping.abc_channel_id)
)
if not allowed_channels_by_workspace:
raise HTTPException(
status_code=403,
detail="프로젝트 피드백 상태를 조회할 권한이 없습니다.",
)
rows = db.execute(
select(
ABCFeedbackMapping.abc_feedback_id,
SupportTicket.feedback_status,
SupportTicket.updated_at,
ABCFeedbackMapping.workspace_id,
ABCFeedbackMapping.abc_channel_id,
)
.join(SupportTicket, SupportTicket.id == ABCFeedbackMapping.ticket_id)
.where(
ABCFeedbackMapping.workspace_id.in_(allowed_channels_by_workspace),
SupportTicket.sync_status != "DELETED",
)
.order_by(SupportTicket.updated_at.desc(), SupportTicket.id.desc())
).all()
result: list[dict[str, int | str]] = []
for feedback_id, status, updated_at, workspace_id, mapped_channel_id in rows:
if str(mapped_channel_id) not in allowed_channels_by_workspace.get(workspace_id, set()):
continue
if not str(feedback_id).isdigit() or updated_at is None:
continue
result.append({
"feedback_id": int(feedback_id),
"feedback_status": status,
"updated_at": serialize_support_timestamp(updated_at) or "",
})
return result
def get_feedback_metrics(
self,
db: Session,
principal,
project_id: int,
channel_id: int | None = None,
) -> dict[str, int | float | None]:
mappings = db.execute(
select(WorkspaceChannelMapping, Workspace)
.join(Workspace, Workspace.id == WorkspaceChannelMapping.workspace_id)
.where(
WorkspaceChannelMapping.abc_project_id == project_id,
WorkspaceChannelMapping.is_active.is_(True),
Workspace.is_active.is_(True),
)
).all()
accessible_workspace_ids: set[int] = set()
for mapping, workspace in mappings:
if channel_id is not None and str(mapping.abc_channel_id) != str(channel_id):
continue
access = access_service.find_workspace_access(
db,
principal,
workspace.workspace_code,
)
if access is not None and (
access.can_manage
or access.workspace_role in {"SYSTEM_ADMIN", "SUPER_ADMIN", "PROJECT_MANAGER"}
):
accessible_workspace_ids.add(workspace.id)
if not accessible_workspace_ids:
raise HTTPException(
status_code=403,
detail="프로젝트 피드백 지표를 조회할 권한이 없습니다.",
)
tickets = db.execute(
select(SupportTicket)
.options(selectinload(SupportTicket.comments))
.where(
SupportTicket.workspace_id.in_(accessible_workspace_ids),
SupportTicket.sync_status != "DELETED",
)
).scalars().all()
today_start = now_support_timestamp().replace(
hour=0,
minute=0,
second=0,
microsecond=0,
)
today_first_count = sum(
1 for ticket in tickets if ticket.created_at >= today_start
)
issue_linked_count = sum(
1 for ticket in tickets if ticket.issue_link_status == "LINKED"
)
answer_waiting_count = sum(
1
for ticket in tickets
if not any(
comment.is_internal
and comment.comment_type != "INTERNAL_MEMO"
and comment.deleted_at is None
for comment in ticket.comments
)
or ticket.issue_link_status != "LINKED"
)
processing_minutes: list[float] = []
for ticket in tickets:
processed_at_candidates = [
comment.created_at
for comment in ticket.comments
if comment.is_internal
and comment.comment_type != "INTERNAL_MEMO"
and comment.deleted_at is None
]
if (
ticket.issue_link_status == "LINKED"
and ticket.updated_at > ticket.created_at
):
processed_at_candidates.append(ticket.updated_at)
if processed_at_candidates:
processed_at = min(processed_at_candidates)
processing_minutes.append(
max(
0.0,
(processed_at - ticket.created_at).total_seconds() / 60,
)
)
return {
"project_id": project_id,
"channel_id": channel_id,
"today_first_count": today_first_count,
"answer_waiting_count": answer_waiting_count,
"issue_linked_count": issue_linked_count,
"issue_link_rate": round(
issue_linked_count / len(tickets) * 100,
1,
) if tickets else 0,
"average_processing_minutes": (
round(sum(processing_minutes) / len(processing_minutes), 1)
if processing_minutes
else None
),
}
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))
.where(SupportTicket.sync_status != "DELETED")
.order_by(SupportTicket.id.desc()),
).unique().scalars().all()
return [self._serialize_ticket(ticket, db) 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, db)
def list_assignee_candidates(
self,
db: Session,
ticket_id: int,
workspace_code: str | None = None,
abc_feedback_id: int | None = None,
) -> list[TicketAssigneeCandidate]:
ticket = self._resolve_ticket_for_external_id(
db,
ticket_id,
workspace_code,
abc_feedback_id=abc_feedback_id,
)
if ticket is None:
raise HTTPException(status_code=404, detail="지원 요청을 찾을 수 없습니다.")
assignments = (
db.query(SupportRoleAssignment, SupportUser)
.join(SupportUser, SupportRoleAssignment.support_user_id == SupportUser.id)
.filter(
SupportUser.status == "ACTIVE",
or_(
SupportRoleAssignment.role_code.in_({"SYSTEM_ADMIN", "SUPER_ADMIN"}),
and_(
SupportRoleAssignment.workspace_id == ticket.workspace_id,
SupportRoleAssignment.role_code == "PROJECT_MANAGER",
),
),
)
.order_by(SupportUser.name.asc(), SupportUser.email.asc())
.all()
)
candidates: dict[tuple[str, str], TicketAssigneeCandidate] = {}
for assignment, user in assignments:
key = (user.sso_subject, user.tenant_id)
existing = candidates.get(key)
if existing is not None and existing.role_code in {"SYSTEM_ADMIN", "SUPER_ADMIN"}:
continue
candidates[key] = TicketAssigneeCandidate(
user_id=user.sso_subject,
tenant_id=user.tenant_id,
name=user.name,
email=user.email,
department=user.department,
phone_number=user.phone_number,
role_code=assignment.role_code,
)
return list(candidates.values())
def update_assignee(
self,
db: Session,
ticket_id: int,
payload: TicketAssigneeUpdateRequest,
workspace_code: str | None = None,
abc_feedback_id: int | None = None,
) -> SupportTicketRecord:
ticket = self._resolve_ticket_for_external_id(
db,
ticket_id,
workspace_code,
abc_feedback_id=abc_feedback_id,
)
if ticket is None:
raise HTTPException(status_code=404, detail="지원 요청을 찾을 수 없습니다.")
if payload.assignee_id is None and payload.assignee_tenant_id is None:
ticket.current_assignee_id = None
ticket.current_assignee_tenant_id = None
elif not payload.assignee_id or not payload.assignee_tenant_id:
raise HTTPException(
status_code=400,
detail="담당자 ID와 tenant ID를 함께 입력해야 합니다.",
)
else:
candidate = (
db.query(SupportRoleAssignment)
.join(SupportUser, SupportRoleAssignment.support_user_id == SupportUser.id)
.filter(
SupportUser.status == "ACTIVE",
SupportUser.sso_subject == payload.assignee_id,
SupportUser.tenant_id == payload.assignee_tenant_id,
or_(
SupportRoleAssignment.role_code.in_({"SYSTEM_ADMIN", "SUPER_ADMIN"}),
and_(
SupportRoleAssignment.workspace_id == ticket.workspace_id,
SupportRoleAssignment.role_code == "PROJECT_MANAGER",
),
),
)
.first()
)
if candidate is None:
raise HTTPException(
status_code=400,
detail="해당 프로젝트의 관리자만 담당자로 지정할 수 있습니다.",
)
ticket.current_assignee_id = payload.assignee_id
ticket.current_assignee_tenant_id = payload.assignee_tenant_id
db.commit()
db.refresh(ticket)
return self._serialize_ticket(ticket, db)
def update_ticket(
self,
db: Session,
ticket_id: int,
payload: TicketUpdateRequest,
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="지원 요청을 찾을 수 없습니다.")
resolved_workspace_code = ticket.workspace.workspace_code
abc_feedback_id = int(
ticket.feedback_mapping.abc_feedback_id
if ticket.feedback_mapping
else ticket_id
)
self._update_abc_feedback(
db,
resolved_workspace_code,
abc_feedback_id,
{
"title": payload.title if payload.title is not None else ticket.title,
"contents": (
payload.description
if payload.description is not None
else ticket.description
),
},
)
if payload.title is not None:
ticket.title = payload.title.strip()
if payload.description is not None:
ticket.description = payload.description.strip()
if payload.requester_contact is not None:
ticket.requester_contact = payload.requester_contact.strip()
if payload.is_secret is not None:
ticket.is_secret = payload.is_secret
if payload.extra_fields is not None:
ticket.extra_fields = payload.extra_fields
ticket.sync_status = "SYNCED"
db.commit()
db.refresh(ticket)
return self._serialize_ticket(ticket, db)
def delete_ticket(
self,
db: Session,
ticket_id: int,
workspace_code: str | None = None,
) -> dict[str, int | bool]:
ticket = self._resolve_ticket_for_external_id(db, ticket_id, workspace_code)
if not ticket:
raise HTTPException(status_code=404, detail="지원 요청을 찾을 수 없습니다.")
resolved_workspace_code = ticket.workspace.workspace_code
abc_feedback_id = int(
ticket.feedback_mapping.abc_feedback_id
if ticket.feedback_mapping
else ticket_id
)
self._delete_abc_feedback(db, resolved_workspace_code, abc_feedback_id)
ticket.sync_status = "DELETED"
if ticket.feedback_mapping:
ticket.feedback_mapping.sync_status = "DELETED"
db.commit()
return {"deleted": True, "ticket_id": ticket_id, "feedback_id": abc_feedback_id}
def list_ticket_comments(
self,
db: Session,
ticket_id: int,
workspace_code: str | None = None,
abc_feedback_id: int | None = None,
requester_id: str | None = None,
requester_tenant_id: str | None = None,
can_manage_comments: bool = False,
include_internal_memos: bool = False,
) -> list[SupportTicketCommentRecord]:
ticket = self._resolve_ticket_for_external_id(
db,
ticket_id,
workspace_code,
abc_feedback_id=abc_feedback_id,
)
if not ticket:
return []
comment_conditions = [
TicketComment.ticket_id == ticket.id,
TicketComment.deleted_at.is_(None),
]
if not include_internal_memos:
comment_conditions.append(
TicketComment.comment_type != "INTERNAL_MEMO"
)
comments = db.execute(
select(TicketComment)
.options(selectinload(TicketComment.attachments))
.where(*comment_conditions)
.order_by(TicketComment.created_at.asc(), TicketComment.id.asc()),
).scalars().all()
return [
self._serialize_comment(
comment,
requester_id,
requester_tenant_id,
can_manage_comments,
)
for comment in comments
]
def list_internal_memos(
self,
db: Session,
ticket_id: int,
workspace_code: str | None = None,
abc_feedback_id: int | None = None,
requester_id: str | None = None,
requester_tenant_id: str | None = None,
can_manage_comments: bool = False,
) -> list[SupportTicketCommentRecord]:
return [
comment
for comment in self.list_ticket_comments(
db,
ticket_id,
workspace_code,
abc_feedback_id,
requester_id,
requester_tenant_id,
can_manage_comments,
True,
)
if comment.is_internal and comment.comment_type == "INTERNAL_MEMO"
]
def create_internal_memo(
self,
db: Session,
ticket_id: int,
payload: TicketCommentCreateRequest,
workspace_code: str | None = None,
abc_feedback_id: int | None = None,
) -> SupportTicketCommentRecord:
return self.create_ticket_comment(
db,
ticket_id,
payload.model_copy(update={"is_internal": True}),
workspace_code,
abc_feedback_id,
comment_type="INTERNAL_MEMO",
)
def update_internal_memo(
self,
db: Session,
ticket_id: int,
memo_id: int,
payload: TicketCommentUpdateRequest,
workspace_code: str | None = None,
abc_feedback_id: int | None = None,
) -> SupportTicketCommentRecord:
comment = self._get_comment(
db,
ticket_id,
memo_id,
workspace_code,
abc_feedback_id,
)
if not comment.is_internal:
raise HTTPException(status_code=404, detail="내부 메모를 찾을 수 없습니다.")
return self.update_ticket_comment(
db,
ticket_id,
memo_id,
payload,
workspace_code,
abc_feedback_id,
)
def delete_internal_memo(
self,
db: Session,
ticket_id: int,
memo_id: int,
requester_id: str,
requester_tenant_id: str,
workspace_code: str | None = None,
abc_feedback_id: int | None = None,
can_manage_comments: bool = False,
) -> dict[str, int | bool]:
comment = self._get_comment(
db,
ticket_id,
memo_id,
workspace_code,
abc_feedback_id,
)
if not comment.is_internal:
raise HTTPException(status_code=404, detail="내부 메모를 찾을 수 없습니다.")
return self.delete_ticket_comment(
db,
ticket_id,
memo_id,
requester_id,
requester_tenant_id,
workspace_code,
abc_feedback_id,
can_manage_comments,
)
def create_ticket_comment(
self,
db: Session,
ticket_id: int,
payload: TicketCommentCreateRequest,
workspace_code: str | None = None,
abc_feedback_id: int | None = None,
attachments: list[UploadFile] | None = None,
comment_type: str = "ADMIN",
) -> 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,
abc_feedback_id=abc_feedback_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,
comment_type=comment_type if payload.is_internal else "COMMENT",
sync_status="LOCAL_ONLY",
)
db.add(comment)
db.flush()
if attachments:
self._save_attachments(
db,
ticket.workspace,
ticket,
payload.requester_id,
payload.requester_tenant_id,
attachments,
comment_id=comment.id,
)
db.commit()
db.refresh(comment)
return self._serialize_comment(
comment,
payload.requester_id,
payload.requester_tenant_id,
)
def update_ticket_comment(
self,
db: Session,
ticket_id: int,
comment_id: int,
payload: TicketCommentUpdateRequest,
workspace_code: str | None = None,
abc_feedback_id: int | None = None,
) -> SupportTicketCommentRecord:
comment = self._get_comment(
db,
ticket_id,
comment_id,
workspace_code,
abc_feedback_id,
)
self._assert_comment_author(
comment,
payload.requester_id,
payload.requester_tenant_id,
)
comment.content = payload.content.strip()
comment.edited_at = now_support_timestamp()
db.commit()
db.refresh(comment)
return self._serialize_comment(
comment,
payload.requester_id,
payload.requester_tenant_id,
)
def delete_ticket_comment(
self,
db: Session,
ticket_id: int,
comment_id: int,
requester_id: str,
requester_tenant_id: str,
workspace_code: str | None = None,
abc_feedback_id: int | None = None,
can_manage_comments: bool = False,
) -> dict[str, int | bool]:
comment = self._get_comment(
db,
ticket_id,
comment_id,
workspace_code,
abc_feedback_id,
)
self._assert_comment_author(
comment,
requester_id,
requester_tenant_id,
can_manage_comments,
)
comment.deleted_at = now_support_timestamp()
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=now_support_timestamp(),
),
)
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.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.sync_status = "SYNCED"
db.commit()
db.refresh(ticket)
return {
"ticket_id": ticket.id,
"issue_link_status": ticket.issue_link_status,
"status_code": ticket.status_code,
"feedback_status": ticket.feedback_status,
"external_issue_status": ticket.external_issue_status,
"linked": linked,
}
def _serialize_ticket(
self,
ticket: SupportTicket,
db: Session | None = None,
) -> SupportTicketRecord:
requester_email = ticket.requester_email
requester_name = ticket.requester_name
requester_department = ticket.requester_department
requester_phone_number = ticket.requester_phone_number
if db is not None:
support_user = db.execute(
select(SupportUser).where(
SupportUser.sso_subject == ticket.requester_id,
SupportUser.tenant_id == ticket.requester_tenant_id,
),
).scalar_one_or_none()
if support_user is not None:
requester_email = requester_email or support_user.email
requester_name = requester_name or support_user.name
requester_department = requester_department or support_user.department
requester_phone_number = requester_phone_number or support_user.phone_number
activity = self._build_activity(ticket)
assignee = None
if ticket.current_assignee_id and ticket.current_assignee_tenant_id and db is not None:
assignee = db.execute(
select(SupportUser).where(
SupportUser.sso_subject == ticket.current_assignee_id,
SupportUser.tenant_id == ticket.current_assignee_tenant_id,
),
).scalar_one_or_none()
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 "",
requester_email=requester_email,
requester_name=requester_name,
requester_department=requester_department,
requester_phone_number=requester_phone_number,
category_code=ticket.category_code or "GENERAL",
ticket_type=ticket.ticket_type,
requires_approval=ticket.requires_approval,
status_code=ticket.status_code,
feedback_status=ticket.feedback_status,
approval_status=ticket.approval_status,
sync_status=ticket.sync_status,
issue_link_status=ticket.issue_link_status,
external_issue_status=ticket.external_issue_status,
assignee_id=ticket.current_assignee_id,
assignee_tenant_id=ticket.current_assignee_tenant_id,
assignee_name=assignee.name if assignee else None,
assignee_email=assignee.email if assignee else None,
is_secret=ticket.is_secret,
created_at=serialize_support_timestamp(ticket.created_at),
updated_at=serialize_support_timestamp(ticket.updated_at),
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=serialize_support_timestamp(attachment.created_at),
)
def _serialize_mutation(self, ticket: SupportTicket) -> TicketMutationResponse:
return TicketMutationResponse(
ticket_id=ticket.id,
status_code=ticket.status_code,
feedback_status=ticket.feedback_status,
approval_status=ticket.approval_status,
issue_link_status=ticket.issue_link_status,
external_issue_status=ticket.external_issue_status,
updated_at=serialize_support_timestamp(ticket.updated_at),
)
def _serialize_comment(
self,
comment: TicketComment,
requester_id: str | None = None,
requester_tenant_id: str | None = None,
can_manage_comments: bool = False,
) -> SupportTicketCommentRecord:
is_author = (
requester_id is not None
and requester_tenant_id is not None
and comment.author_id == requester_id
and comment.author_tenant_id == requester_tenant_id
)
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,
comment_type=comment.comment_type,
created_at=serialize_support_timestamp(comment.created_at),
updated_at=serialize_support_timestamp(comment.updated_at),
edited_at=serialize_support_timestamp(comment.edited_at),
attachments=[
self._serialize_attachment(attachment)
for attachment in comment.attachments
if attachment.deleted_at is None
and attachment.attachment_status == "ACTIVE"
],
can_edit=is_author,
can_delete=is_author or can_manage_comments,
)
def _build_activity(self, ticket: SupportTicket) -> list[SupportActivityItem]:
items = [
SupportActivityItem(
id=f"received-{ticket.id}",
label="접수",
detail="사용자가 지원 요청을 등록했습니다.",
timestamp=serialize_support_timestamp(ticket.created_at),
),
]
if ticket.approval_status == "PENDING":
items.insert(
0,
SupportActivityItem(
id=f"approval-pending-{ticket.id}",
label="승인 대기",
detail="승인형 요청으로 분류되어 승인 큐에 등록되었습니다.",
timestamp=serialize_support_timestamp(ticket.updated_at),
),
)
if ticket.approval_status == "APPROVED":
items.insert(
0,
SupportActivityItem(
id=f"approved-{ticket.id}",
label="승인 완료",
detail="운영 승인 큐에서 승인 처리되었습니다.",
timestamp=serialize_support_timestamp(ticket.updated_at),
),
)
if ticket.issue_link_status == "LINKED":
items.insert(
0,
SupportActivityItem(
id=f"linked-{ticket.id}",
label="이슈 연결",
detail="관리자 화면에서 이슈 연결이 완료되었습니다.",
timestamp=serialize_support_timestamp(ticket.updated_at),
),
)
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,
db: Session,
workspace_code: str,
) -> ABCProjectTarget:
workspace_mapping_service.sync_missing_mappings(db, [workspace_code])
mapping = (
db.execute(
select(WorkspaceChannelMapping)
.join(Workspace)
.where(
Workspace.workspace_code == workspace_code,
WorkspaceChannelMapping.is_active.is_(True),
WorkspaceChannelMapping.abc_project_id.is_not(None),
)
.order_by(WorkspaceChannelMapping.id.asc()),
)
.scalars()
.first()
)
if mapping is None or mapping.abc_project_id is None:
raise HTTPException(
status_code=503,
detail=(
f"ABC 프로젝트 매핑이 설정되지 않은 workspace입니다: "
f"{workspace_code}"
),
)
try:
project_id = int(mapping.abc_project_id)
channel_id = int(mapping.abc_channel_id)
except (TypeError, ValueError) as exc:
raise HTTPException(
status_code=503,
detail=f"ABC 프로젝트 매핑 값이 올바르지 않습니다: {workspace_code}",
) from exc
if project_id <= 0 or channel_id <= 0:
raise HTTPException(
status_code=503,
detail=f"ABC 프로젝트 매핑 값이 올바르지 않습니다: {workspace_code}",
)
return ABCProjectTarget(
project_id=project_id,
channel_id=channel_id,
)
def _resolve_api_key(self, target: ABCProjectTarget) -> str:
return target.api_key or settings.abc_api_key
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,
workspace_code: str | None = None,
abc_feedback_id: int | None = None,
) -> TicketComment:
ticket = self._resolve_ticket_for_external_id(
db,
ticket_id,
workspace_code,
abc_feedback_id=abc_feedback_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,
abc_feedback_id: int | None = None,
) -> SupportTicket | None:
if abc_feedback_id is not None:
mapped_statement = (
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(abc_feedback_id),
SupportTicket.sync_status != "DELETED",
)
)
if workspace_code:
mapped_statement = mapped_statement.join(Workspace).where(
Workspace.workspace_code == workspace_code,
)
mapped_ticket = db.execute(mapped_statement).unique().scalar_one_or_none()
if mapped_ticket:
return mapped_ticket
# Legacy rows may have been created before abc_feedback_mappings
# existed. Reuse an unmapped ticket with the same local id, but do
# not reuse a ticket that is explicitly mapped to another ABC id.
legacy_statement = (
select(SupportTicket)
.options(
joinedload(SupportTicket.workspace),
joinedload(SupportTicket.feedback_mapping),
joinedload(SupportTicket.approvals),
joinedload(SupportTicket.attachments),
)
.where(
SupportTicket.id == external_ticket_id,
SupportTicket.sync_status != "DELETED",
)
)
if workspace_code:
legacy_statement = legacy_statement.join(Workspace).where(
Workspace.workspace_code == workspace_code,
)
legacy_ticket = db.execute(legacy_statement).unique().scalar_one_or_none()
if legacy_ticket and (
legacy_ticket.feedback_mapping is None
or legacy_ticket.feedback_mapping.abc_feedback_id == str(abc_feedback_id)
):
return legacy_ticket
if workspace_code:
migrated_ticket = self._resolve_migrated_ticket_from_abc_feedback(
db,
abc_feedback_id,
workspace_code,
)
if migrated_ticket:
return migrated_ticket
return self._provision_ticket_from_abc_feedback(
db,
abc_feedback_id,
workspace_code,
requester_id,
requester_tenant_id,
)
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,
SupportTicket.sync_status != "DELETED",
),
).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),
SupportTicket.sync_status != "DELETED",
),
).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 _resolve_migrated_ticket_from_abc_feedback(
self,
db: Session,
abc_feedback_id: int,
workspace_code: str,
) -> SupportTicket | None:
"""Resolve an imported EGBIM post before creating a duplicate ticket.
Imported ABC feedback IDs are generated by the ABC database and do not
equal the legacy EGBIM post IDs. The imported feedback JSON carries the
legacy post ID, which is linked to support_tickets through
baron_support.migration_mappings.
"""
try:
feedback = self._fetch_abc_feedback(db, workspace_code, abc_feedback_id)
except HTTPException:
return None
raw_data = feedback.get("data")
if isinstance(raw_data, str):
try:
raw_data = json.loads(raw_data)
except json.JSONDecodeError:
raw_data = None
source_post_id = None
if isinstance(raw_data, dict):
source_post_id = raw_data.get("source_post_id")
if source_post_id is None:
source_post_id = feedback.get("source_post_id")
if source_post_id is None:
return None
mapping = db.execute(
sql_text(
"""
SELECT mm.ticket_id
FROM migration_mappings mm
JOIN workspaces w ON w.id = mm.workspace_id
WHERE mm.source_system = :source_system
AND mm.source_entity_type = 'POST'
AND mm.source_entity_id = :source_entity_id
AND mm.workspace_id = w.id
AND w.workspace_code = :workspace_code
AND mm.ticket_id IS NOT NULL
AND mm.migration_status <> 'FAILED'
ORDER BY mm.id DESC
LIMIT 1
""",
),
{
"source_system": "EGBIM_QA",
"source_entity_id": str(source_post_id),
"workspace_code": workspace_code,
},
).mappings().first()
if not mapping or mapping["ticket_id"] is None:
return None
statement = (
select(SupportTicket)
.options(
joinedload(SupportTicket.workspace),
joinedload(SupportTicket.feedback_mapping),
joinedload(SupportTicket.approvals),
joinedload(SupportTicket.attachments),
)
.where(
SupportTicket.id == int(mapping["ticket_id"]),
SupportTicket.sync_status != "DELETED",
)
)
return db.execute(statement).unique().scalar_one_or_none()
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
target = self._resolve_workspace_target(db, workspace_code)
feedback = self._fetch_abc_feedback(db, workspace_code, 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(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)
db.commit()
db.refresh(ticket)
return ticket
def _fetch_abc_feedback(
self,
db: Session,
workspace_code: str,
feedback_id: int,
) -> dict[str, object]:
target = self._resolve_workspace_target(db, workspace_code)
api_key = self._resolve_api_key(target)
if not api_key:
raise HTTPException(status_code=500, detail="ABC API key is not configured.")
endpoint = (
f"{settings.abc_api_base_url}/api/v2/projects/"
f"{target.project_id}/channels/{target.channel_id}/feedbacks/search"
)
payload = json.dumps({"query": {}}).encode("utf-8")
req = request.Request(
endpoint,
headers={
"x-api-key": api_key,
"Content-Type": "application/json",
},
data=payload,
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 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.")
items = payload_data.get("items")
if not isinstance(items, list):
raise HTTPException(status_code=502, detail="ABC API returned an invalid feedback search payload.")
feedback = next(
(
item
for item in items
if isinstance(item, dict) and int(item.get("id", -1)) == feedback_id
),
None,
)
if not isinstance(feedback, dict):
raise HTTPException(status_code=404, detail="ABC 피드백을 찾을 수 없습니다.")
return feedback
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:
if attachment.storage_provider.upper() == "R2":
raise HTTPException(
status_code=500,
detail="R2 첨부파일은 파일 경로로 조회할 수 없습니다.",
)
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 read_attachment(self, attachment: Attachment) -> bytes:
if attachment.storage_provider.upper() == "R2":
try:
return storage_service.get_bytes(
attachment.storage_key,
attachment.storage_bucket,
)
except Exception as exc:
raise HTTPException(
status_code=404,
detail="R2 첨부파일을 찾을 수 없습니다.",
) from exc
return self.resolve_attachment_path(attachment).read_bytes()
def _assert_comment_author(
self,
comment: TicketComment,
requester_id: str,
requester_tenant_id: str,
allow_any_author: bool = False,
) -> None:
if allow_any_author:
return
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",
"Q&A_Platform": "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)
if workspace_code in {"Q&A_Platform", "EGBIM_DEMO"} and normalized not in QNA_CATEGORY_CODES:
return "GENERAL_QNA"
return normalized