Files
ai-ticket-platform/backend/apps/tickets/replies.py
2026-09-18 15:26:19 +08:00

288 lines
9.4 KiB
Python

"""客户回复草稿的修订、渠道批准与送达确认领域服务。"""
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