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"} 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) 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"), ], ), } 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 == "Q&A_Platform" and normalized not in QNA_CATEGORY_CODES: return "GENERAL_QNA" return normalized