Files
2026-07-27 18:59:28 +08:00

322 lines
13 KiB
Python

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)