"""客户回复草稿的修订、渠道批准与送达确认领域服务。""" from django.db import transaction from django.utils import timezone from apps.audit.models import OperationAuditLog from apps.tickets.models import ( CustomerReplyRevision, CustomerReplyTask, Ticket, TicketActorSource, TicketSupportAnalysis, ) from apps.workitems.services import sync_customer_reply_work_item class CustomerReplyError(ValueError): pass def _normalized_reply(value: str) -> str: normalized = value.strip() if not normalized: raise CustomerReplyError("客户回复内容不能为空") if len(normalized) > 10000: raise CustomerReplyError("客户回复内容不能超过 10000 个字符") return normalized @transaction.atomic def ensure_customer_reply_task( analysis: TicketSupportAnalysis, draft_text: str, ) -> CustomerReplyTask | None: """为可信分析创建或复用客户回复任务,并冻结初始建议版本。""" normalized = draft_text.strip() if not normalized: return None analysis = ( TicketSupportAnalysis.objects.select_for_update(of=("self",)) .select_related("ticket", "run", "created_by") .get(id=analysis.id) ) task = ( CustomerReplyTask.objects.select_for_update() .filter(ticket=analysis.ticket, status__in=["draft", "approved"]) .first() ) if task is None: task = CustomerReplyTask.objects.create( ticket=analysis.ticket, support_analysis=analysis, draft_text=normalized, final_text=normalized, created_by=analysis.created_by, ) CustomerReplyRevision.objects.create( task=task, version=1, content=normalized, source=TicketActorSource.AGENT, ) elif task.status == "draft": has_human_edit = task.revisions.filter(source=TicketActorSource.HUMAN).exists() task.support_analysis = analysis task.draft_text = normalized update_fields = ["support_analysis", "draft_text", "updated_at"] if not has_human_edit and task.final_text != normalized: task.revision += 1 task.final_text = normalized update_fields.extend(["revision", "final_text"]) CustomerReplyRevision.objects.create( task=task, version=task.revision, content=normalized, source=TicketActorSource.AGENT, ) task.save(update_fields=update_fields) else: sync_customer_reply_work_item(task, actor=analysis.created_by) return task sync_customer_reply_work_item(task, actor=analysis.created_by) analysis.ticket.events.create( event_type="customer_reply_draft_created", to_value={ "reply_task_id": task.id, "revision": task.revision, "support_analysis_id": analysis.id, }, operator=analysis.created_by, source=TicketActorSource.AGENT, agent_run_id=analysis.run_id, ) return task @transaction.atomic def update_customer_reply( task: CustomerReplyTask, final_text: str, *, expected_revision: int, actor, ) -> CustomerReplyTask: """使用乐观版本号修订回复,已批准或已发送版本不可被静默覆盖。""" task = ( CustomerReplyTask.objects.select_for_update(of=("self",)) .select_related("ticket", "support_analysis") .get(id=task.id) ) if task.status != "draft": raise CustomerReplyError("已确认渠道发送、已发送或已取消的回复不能再编辑") if task.revision != expected_revision: raise CustomerReplyError("回复草稿已被更新,请刷新后重试") normalized = _normalized_reply(final_text) if normalized == task.final_text: return task task.revision += 1 task.final_text = normalized task.updated_by = actor task.save(update_fields=["revision", "final_text", "updated_by", "updated_at"]) CustomerReplyRevision.objects.create( task=task, version=task.revision, content=normalized, source=TicketActorSource.HUMAN, edited_by=actor, ) sync_customer_reply_work_item(task, actor=actor) task.ticket.events.create( event_type="customer_reply_edited", to_value={"reply_task_id": task.id, "revision": task.revision}, operator=actor, source=TicketActorSource.HUMAN, agent_run_id=( task.support_analysis.run_id if task.support_analysis_id else None ), ) OperationAuditLog.objects.create( user=actor, module="tickets", action="customer_reply_edited", target_type="customer_reply_task", target_id=str(task.id), request_snapshot={"ticket_id": task.ticket_id, "revision": task.revision}, result="success", ) return task @transaction.atomic def approve_customer_reply_for_channel( task: CustomerReplyTask, *, expected_revision: int, actor, channel_endpoint_id: int, external_conversation_id: str, ) -> CustomerReplyTask: """批准指定回复版本进入唯一渠道会话,不在此函数直接发送消息。""" task = ( CustomerReplyTask.objects.select_for_update(of=("self",)) .select_related("ticket", "support_analysis") .get(id=task.id) ) if task.status == "approved": if task.revision != expected_revision: raise CustomerReplyError("回复版本与已确认版本不一致") return task if task.status != "draft": raise CustomerReplyError("只有待确认回复可以进入渠道发送") if task.revision != expected_revision: raise CustomerReplyError("回复草稿已被更新,请刷新后重试") _normalized_reply(task.final_text) now = timezone.now() task.status = "approved" task.approved_by = actor task.approved_at = now task.save( update_fields=["status", "approved_by", "approved_at", "updated_at"] ) sync_customer_reply_work_item(task, actor=actor) task.ticket.events.create( event_type="customer_reply_channel_approved", to_value={ "reply_task_id": task.id, "revision": task.revision, "channel_endpoint_id": channel_endpoint_id, "external_conversation_id": external_conversation_id, "approved_at": now.isoformat(), }, operator=actor, source=TicketActorSource.HUMAN, agent_run_id=( task.support_analysis.run_id if task.support_analysis_id else None ), ) OperationAuditLog.objects.create( user=actor, module="tickets", action="customer_reply_channel_approved", target_type="customer_reply_task", target_id=str(task.id), request_snapshot={ "ticket_id": task.ticket_id, "revision": task.revision, "channel_endpoint_id": channel_endpoint_id, "external_conversation_id": external_conversation_id, }, result="success", ) return task @transaction.atomic def confirm_customer_reply_sent( task: CustomerReplyTask, *, expected_revision: int, actor, source: str = TicketActorSource.HUMAN, channel_message_public_id: str = "", ) -> CustomerReplyTask: """仅在人工确认或渠道明确送达后标记回复完成并记录审计。""" task = ( CustomerReplyTask.objects.select_for_update(of=("self",)) .select_related("ticket", "support_analysis") .get(id=task.id) ) if task.status == "sent": return task if task.status not in {"draft", "approved"}: raise CustomerReplyError("已取消的回复不能确认发送") if task.revision != expected_revision: raise CustomerReplyError("回复草稿已被更新,请刷新后重试") _normalized_reply(task.final_text) now = timezone.now() task.status = "sent" task.sent_by = actor task.sent_at = now task.save(update_fields=["status", "sent_by", "sent_at", "updated_at"]) Ticket.objects.filter(id=task.ticket_id).update( replied_to_customer=True, support_last_reply_at=now, updated_at=now, ) sync_customer_reply_work_item(task, actor=actor) task.ticket.events.create( event_type="customer_reply_sent", to_value={ "reply_task_id": task.id, "revision": task.revision, "sent_at": now.isoformat(), "channel_message_public_id": channel_message_public_id, }, operator=actor, source=source, agent_run_id=( task.support_analysis.run_id if task.support_analysis_id else None ), ) OperationAuditLog.objects.create( user=actor, module="tickets", action="customer_reply_sent", target_type="customer_reply_task", target_id=str(task.id), request_snapshot={ "ticket_id": task.ticket_id, "revision": task.revision, "channel_message_public_id": channel_message_public_id, }, result="success", ) reply_task_id = task.id def resume_ticket_automation() -> None: from apps.tickets.automation import handle_customer_reply_delivered handle_customer_reply_delivered(reply_task_id) transaction.on_commit(resume_ticket_automation) return task