from datetime import timedelta from django.conf import settings from django.db import transaction from django.utils import timezone from ninja import File, Router, UploadedFile from ninja.errors import HttpError from apps.accounts.api import require_permission from apps.tickets.api import serialize_ticket from apps.tickets.draft_schemas import ( TicketDraftActionIn, TicketDraftAnswerIn, TicketDraftAvailabilityOut, TicketDraftCreateIn, TicketDraftOut, TicketDraftPatchIn, ) from apps.tickets.draft_services import ( ACTIVE_DRAFT_STATUSES, TicketDraftError, add_answer, assert_revision, audit_draft, cancel_draft_analysis, confirm_draft, create_agent_run_for_draft, draft_availability, reject_draft, serialize_draft, ) from apps.tickets.materials import ( MaterialValidationError, prepare_material, process_material, ) from apps.tickets.models import TicketDraft, TicketDraftMaterial from apps.tickets.storage import delete_object, put_object from apps.tickets.tasks import execute_ticket_draft_analysis router = Router(tags=["ticket-drafts"]) def get_owned_draft(request, public_id: str, *, allow_admin_view: bool = False) -> TicketDraft: require_permission(request, "ticket.ai_draft") queryset = TicketDraft.objects.select_related( "latest_agent_run", "confirmed_ticket", "created_by" ).prefetch_related("materials", "messages") draft = queryset.filter(public_id=public_id).first() if draft is None: raise HttpError(404, "草稿不存在") if draft.created_by_id != request.user.id: can_view = allow_admin_view and ( request.user.is_superuser or request.user.has_perm("system.agent.run.view") ) if not can_view: raise HttpError(403, "不能访问其他人的草稿") return draft def raise_draft_error(error: TicketDraftError) -> None: raise HttpError(error.status_code, str(error)) from error @router.get("/availability", response=TicketDraftAvailabilityOut) def get_availability(request): require_permission(request, "ticket.ai_draft") return draft_availability() @router.get("", response=list[TicketDraftOut]) def list_ticket_drafts(request): require_permission(request, "ticket.ai_draft") drafts = ( TicketDraft.objects.filter(created_by=request.user, status__in=ACTIVE_DRAFT_STATUSES) .select_related("latest_agent_run", "confirmed_ticket") .prefetch_related("materials", "messages")[:20] ) return [serialize_draft(draft, ticket_serializer=serialize_ticket) for draft in drafts] @router.post("", response=TicketDraftOut) def create_ticket_draft(request, payload: TicketDraftCreateIn): require_permission(request, "ticket.ai_draft") availability = draft_availability() if not availability["available"]: raise HttpError(422, availability["reason"]) source_text = payload.source_text.strip() draft = TicketDraft.objects.create(created_by=request.user, source_text=source_text) audit_draft(draft, "draft_created") return serialize_draft(draft, ticket_serializer=serialize_ticket) @router.get("/{public_id}", response=TicketDraftOut) def get_ticket_draft(request, public_id: str): draft = get_owned_draft(request, public_id, allow_admin_view=True) return serialize_draft(draft, ticket_serializer=serialize_ticket) @router.patch("/{public_id}", response=TicketDraftOut) def update_ticket_draft(request, public_id: str, payload: TicketDraftPatchIn): with transaction.atomic(): draft = ( TicketDraft.objects.select_for_update() .filter(public_id=public_id, created_by=request.user) .first() ) if draft is None: require_permission(request, "ticket.ai_draft") raise HttpError(404, "草稿不存在") require_permission(request, "ticket.ai_draft") try: assert_revision(draft, payload.expected_revision) except TicketDraftError as error: raise_draft_error(error) if draft.status not in {"collecting", "needs_input", "failed", "editing", "ready"}: raise HttpError(400, "当前状态不能编辑") draft.current_fields = payload.fields draft.revision += 1 draft.expires_at = timezone.now() + timedelta(hours=settings.TICKET_DRAFT_RETENTION_HOURS) draft.save() audit_draft(draft, "draft_updated") draft = get_owned_draft(request, public_id) return serialize_draft(draft, ticket_serializer=serialize_ticket) def _upload_material( request, public_id: str, file: UploadedFile, *, images_only: bool = False, ): draft = get_owned_draft(request, public_id) if draft.status not in {"collecting", "needs_input", "failed", "editing", "ready"}: raise HttpError(400, "当前状态不能上传材料") if draft.materials.count() >= settings.TICKET_MATERIAL_MAX_FILES: raise HttpError(413, f"每次受理最多上传 {settings.TICKET_MATERIAL_MAX_FILES} 个材料") raw = file.read() try: prepared = prepare_material(raw, file.name or "material", file.content_type or "") except MaterialValidationError as error: raise HttpError(error.status_code, str(error)) from error if images_only and prepared.material_type != "image": raise HttpError(415, "旧图片接口仅支持 JPEG、PNG 和 WebP") current_total = sum(material.file_size for material in draft.materials.all()) if current_total + len(prepared.data) > settings.TICKET_MATERIAL_MAX_TOTAL_BYTES: max_mb = settings.TICKET_MATERIAL_MAX_TOTAL_BYTES // 1024 // 1024 raise HttpError(413, f"单次受理材料总大小不能超过 {max_mb} MB") material = TicketDraftMaterial( draft=draft, file_name=prepared.file_name, material_type=prepared.material_type, extension=prepared.extension, claimed_mime_type=prepared.claimed_mime_type, mime_type=prepared.mime_type, file_size=len(prepared.data), sha256=prepared.sha256, storage_path="", ) material.storage_path = ( f"ticket-drafts/{draft.created_by_id}/{draft.public_id}/" f"{material.public_id}.{prepared.extension}" ) try: put_object(material.storage_path, prepared.data, prepared.mime_type) except Exception as error: raise HttpError(503, "材料存储暂时不可用") from error try: material.save() except Exception: try: delete_object(material.storage_path) except Exception: pass raise process_material(material, prepared.data) draft.revision += 1 draft.expires_at = timezone.now() + timedelta(hours=settings.TICKET_DRAFT_RETENTION_HOURS) draft.save(update_fields=["revision", "expires_at", "updated_at"]) audit_draft(draft, "draft_material_uploaded") return serialize_draft(get_owned_draft(request, public_id), ticket_serializer=serialize_ticket) @router.post("/{public_id}/materials", response=TicketDraftOut) def upload_ticket_draft_material(request, public_id: str, file: File[UploadedFile]): return _upload_material(request, public_id, file) @router.post("/{public_id}/images", response=TicketDraftOut) def upload_ticket_draft_image(request, public_id: str, file: File[UploadedFile]): return _upload_material(request, public_id, file, images_only=True) def _delete_material(request, public_id: str, material_id: str, *, images_only: bool = False): draft = get_owned_draft(request, public_id) materials = draft.materials.filter(public_id=material_id, status="temporary") if images_only: materials = materials.filter(material_type="image") material = materials.first() if material is None: raise HttpError(404, "材料不存在") try: delete_object(material.storage_path) except Exception as error: raise HttpError(503, "材料存储暂时不可用") from error material.delete() draft.revision += 1 draft.save(update_fields=["revision", "updated_at"]) audit_draft(draft, "draft_material_deleted") return serialize_draft(get_owned_draft(request, public_id), ticket_serializer=serialize_ticket) @router.delete("/{public_id}/materials/{material_id}", response=TicketDraftOut) def delete_ticket_draft_material(request, public_id: str, material_id: str): return _delete_material(request, public_id, material_id) @router.delete("/{public_id}/images/{image_id}", response=TicketDraftOut) def delete_ticket_draft_image(request, public_id: str, image_id: str): return _delete_material(request, public_id, image_id, images_only=True) def start_analysis(draft: TicketDraft, expected_revision: int) -> TicketDraft: try: assert_revision(draft, expected_revision) if draft.status not in {"collecting", "needs_input", "failed", "editing", "ready"}: raise TicketDraftError(400, "当前状态不能启动分析") ready_materials = draft.materials.filter(scan_status="clean") usable_documents = ready_materials.filter( parse_status="parsed" ).exclude(parsed_text="") ready_images = ready_materials.filter(material_type="image") if ( not draft.source_text.strip() and not draft.current_fields and not ready_images.exists() and not usable_documents.exists() ): raise TicketDraftError(400, "请先输入问题描述或上传可用材料") if ready_images.exists() and not draft_availability()["vision_available"]: raise TicketDraftError(422, draft_availability()["vision_reason"]) run = create_agent_run_for_draft(draft) except TicketDraftError as error: raise_draft_error(error) result = execute_ticket_draft_analysis.delay(draft.id, run.id) run.refresh_from_db() if not run.task_id: run.task_id = result.id or "" run.save(update_fields=["task_id"]) draft.refresh_from_db() return draft @router.post("/{public_id}/analyze", response=TicketDraftOut) def analyze_ticket_draft(request, public_id: str, payload: TicketDraftActionIn): draft = get_owned_draft(request, public_id) draft = start_analysis(draft, payload.expected_revision) return serialize_draft(draft, ticket_serializer=serialize_ticket) @router.post("/{public_id}/cancel", response=TicketDraftOut) def cancel_ticket_draft_analysis( request, public_id: str, payload: TicketDraftActionIn, ): require_permission(request, "ticket.ai_draft") try: draft = cancel_draft_analysis( public_id, payload.expected_revision, request.user, ) except TicketDraft.DoesNotExist as error: raise HttpError(404, "草稿不存在") from error except TicketDraftError as error: raise_draft_error(error) return serialize_draft(draft, ticket_serializer=serialize_ticket) @router.post("/{public_id}/answers", response=TicketDraftOut) def answer_ticket_draft(request, public_id: str, payload: TicketDraftAnswerIn): draft = get_owned_draft(request, public_id) try: assert_revision(draft, payload.expected_revision) if not payload.answer.strip(): raise TicketDraftError(400, "回答不能为空") add_answer(draft, payload.answer, payload.question) except TicketDraftError as error: raise_draft_error(error) draft.refresh_from_db() draft = start_analysis(draft, draft.revision) return serialize_draft(draft, ticket_serializer=serialize_ticket) @router.post("/{public_id}/confirm", response=TicketDraftOut) def confirm_ticket_draft(request, public_id: str, payload: TicketDraftActionIn): require_permission(request, "ticket.ai_draft") require_permission(request, "ticket.create") try: draft = confirm_draft(public_id, payload.expected_revision, request.user) except TicketDraft.DoesNotExist as error: raise HttpError(404, "草稿不存在") from error except TicketDraftError as error: raise_draft_error(error) draft = get_owned_draft(request, public_id) return serialize_draft(draft, ticket_serializer=serialize_ticket) @router.post("/{public_id}/reject", response=TicketDraftOut) def reject_ticket_draft(request, public_id: str, payload: TicketDraftActionIn): require_permission(request, "ticket.ai_draft") try: draft = reject_draft(public_id, payload.expected_revision, request.user) except TicketDraft.DoesNotExist as error: raise HttpError(404, "草稿不存在") from error except TicketDraftError as error: raise_draft_error(error) draft = get_owned_draft(request, public_id) return serialize_draft(draft, ticket_serializer=serialize_ticket)