feat: add intelligent support workflow
This commit is contained in:
279
backend/apps/tickets/replies.py
Normal file
279
backend/apps/tickets/replies.py
Normal file
@@ -0,0 +1,279 @@
|
||||
"""客户回复草稿的修订、渠道批准与送达确认领域服务。"""
|
||||
|
||||
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()
|
||||
.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()
|
||||
.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()
|
||||
.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()
|
||||
.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",
|
||||
)
|
||||
return task
|
||||
Reference in New Issue
Block a user