Files
ai-ticket-platform/backend/apps/channels/services.py

376 lines
14 KiB
Python

"""企业渠道消息的签名校验、幂等收发、回执和客户回复任务联动。"""
import hashlib
import hmac
import json
from urllib.error import HTTPError, URLError
from urllib.parse import urlparse
from urllib.request import Request, urlopen
from django.db import transaction
from django.utils import timezone
from apps.audit.models import OperationAuditLog
from apps.channels.models import (
ChannelConversation,
ChannelEndpoint,
ChannelMessage,
ChannelReceipt,
)
from apps.modelhub.crypto import decrypt_secret
from apps.tickets.models import CustomerReplyTask, TicketActorSource
from apps.tickets.replies import (
CustomerReplyError,
approve_customer_reply_for_channel,
confirm_customer_reply_sent,
)
class ChannelError(ValueError):
pass
def verify_webhook_signature(endpoint: ChannelEndpoint, body: bytes, signature: str) -> None:
"""使用常量时间比较验证原始请求体的 HMAC-SHA256 签名。"""
if not endpoint.webhook_secret_encrypted:
raise ChannelError("渠道未配置 Webhook 密钥")
secret = decrypt_secret(endpoint.webhook_secret_encrypted).encode()
expected = hmac.new(secret, body, hashlib.sha256).hexdigest()
normalized = signature.removeprefix("sha256=").strip()
if not normalized or not hmac.compare_digest(expected, normalized):
raise ChannelError("Webhook 签名无效")
@transaction.atomic
def ingest_inbound_message(endpoint: ChannelEndpoint, payload: dict) -> ChannelMessage:
"""以渠道消息 ID 幂等写入入站消息和会话,不在此处隐式创建工单。"""
endpoint = ChannelEndpoint.objects.select_for_update().get(id=endpoint.id)
if not endpoint.enabled:
raise ChannelError("渠道已停用")
external_message_id = str(payload.get("external_message_id") or "").strip()
conversation_id = str(payload.get("external_conversation_id") or "").strip()
text = str(payload.get("text") or "").strip()
if not external_message_id or not conversation_id or not text:
raise ChannelError("消息 ID、会话 ID 和文本不能为空")
existing = ChannelMessage.objects.filter(
endpoint=endpoint,
direction="inbound",
external_message_id=external_message_id,
).first()
if existing is not None:
return existing
conversation, _ = ChannelConversation.objects.get_or_create(
endpoint=endpoint,
external_conversation_id=conversation_id,
defaults={
"title": str(payload.get("title") or "")[:200],
"participants": payload.get("participants")
if isinstance(payload.get("participants"), list)
else [],
},
)
message = ChannelMessage.objects.create(
endpoint=endpoint,
conversation=conversation,
ticket=conversation.ticket,
direction="inbound",
external_message_id=external_message_id,
idempotency_key=external_message_id,
sender=str(payload.get("sender") or "")[:160],
text=text,
payload=payload.get("payload") if isinstance(payload.get("payload"), dict) else {},
status="received",
occurred_at=payload.get("occurred_at"),
)
OperationAuditLog.objects.create(
module="channels",
action="message_received",
target_type="channel_message",
target_id=str(message.public_id),
request_snapshot={
"endpoint_id": endpoint.id,
"external_message_id": external_message_id,
"conversation_id": conversation_id,
},
result="success",
)
return message
def _validate_outbound_url(value: str) -> None:
parsed = urlparse(value)
if parsed.scheme not in {"http", "https"} or not parsed.netloc or parsed.username:
raise ChannelError("出站地址必须是无内嵌凭证的 HTTP(S) URL")
def _confirm_delivered_reply(message: ChannelMessage) -> None:
if not message.customer_reply_task_id:
return
try:
confirm_customer_reply_sent(
message.customer_reply_task,
expected_revision=message.reply_revision,
actor=message.created_by,
source=TicketActorSource.CHANNEL,
channel_message_public_id=str(message.public_id),
)
except CustomerReplyError as error:
raise ChannelError(f"渠道已送达,但回复任务回写失败:{error}") from error
def deliver_outbound_message(message_id: int) -> ChannelMessage:
"""投递已批准的回复版本,并只在明确送达后完成客户回复任务。"""
message = ChannelMessage.objects.select_related(
"endpoint",
"conversation",
"customer_reply_task",
).get(id=message_id)
endpoint = message.endpoint
if message.status == "delivered":
_confirm_delivered_reply(message)
return message
if message.status not in {"queued", "failed"}:
return message
if not endpoint.enabled or not endpoint.outbound_url:
message.status = "failed"
message.error_message = "渠道未启用或未配置出站地址"
message.save(update_fields=["status", "error_message", "updated_at"])
return message
headers = {
"Content-Type": "application/json",
"Accept": "application/json",
"Idempotency-Key": message.idempotency_key,
}
if endpoint.outbound_token_encrypted:
headers["Authorization"] = (
f"Bearer {decrypt_secret(endpoint.outbound_token_encrypted)}"
)
body = json.dumps(
{
"conversation_id": message.conversation.external_conversation_id,
"text": message.text,
"message_id": str(message.public_id),
"metadata": message.payload,
},
ensure_ascii=False,
).encode()
try:
_validate_outbound_url(endpoint.outbound_url)
request = Request(endpoint.outbound_url, data=body, headers=headers, method="POST")
with urlopen(request, timeout=30) as response:
result = json.loads(response.read().decode("utf-8"))
if not isinstance(result, dict):
raise ChannelError("渠道返回结构无效")
except (
HTTPError,
URLError,
TimeoutError,
json.JSONDecodeError,
ChannelError,
) as error:
message.status = "failed"
message.error_message = str(error) or "渠道发送失败"
else:
message.status = str(result.get("status") or "sent")
if message.status not in {"sent", "delivered"}:
message.status = "sent"
message.external_message_id = str(result.get("external_message_id") or "")
message.error_message = ""
message.sent_at = timezone.now()
if message.status == "delivered":
message.delivered_at = timezone.now()
message.save()
if message.status == "delivered":
_confirm_delivered_reply(message)
OperationAuditLog.objects.create(
user=message.created_by,
module="channels",
action="message_delivery_finished",
target_type="channel_message",
target_id=str(message.public_id),
request_snapshot={
"endpoint_id": endpoint.id,
"status": message.status,
"idempotency_key": message.idempotency_key,
},
result="success" if message.status in {"sent", "delivered"} else "failed",
)
return message
@transaction.atomic
def queue_outbound_message(
endpoint: ChannelEndpoint,
payload: dict,
*,
actor,
) -> ChannelMessage:
"""冻结客户回复版本并生成唯一出站消息,提交后再投递异步任务。"""
endpoint = ChannelEndpoint.objects.select_for_update().get(id=endpoint.id)
if not endpoint.enabled:
raise ChannelError("渠道已停用")
idempotency_key = str(payload.get("idempotency_key") or "").strip()
conversation_id = str(payload.get("external_conversation_id") or "").strip()
if not idempotency_key or not conversation_id:
raise ChannelError("幂等键和会话 ID 不能为空")
try:
reply_task_id = int(payload.get("customer_reply_task_id"))
expected_revision = int(payload.get("expected_revision"))
except (TypeError, ValueError) as error:
raise ChannelError("客户回复任务和版本不能为空") from error
existing = (
ChannelMessage.objects.select_related("conversation")
.filter(
endpoint=endpoint,
direction="outbound",
idempotency_key=idempotency_key,
)
.first()
)
if existing is not None:
if (
existing.customer_reply_task_id == reply_task_id
and existing.reply_revision == expected_revision
and existing.conversation
and existing.conversation.external_conversation_id == conversation_id
):
return existing
raise ChannelError("幂等键已用于其他客户回复")
reply_task = (
CustomerReplyTask.objects.select_related("ticket", "support_analysis")
.filter(id=reply_task_id)
.first()
)
if reply_task is None:
raise ChannelError("客户回复任务不存在")
try:
reply_task = approve_customer_reply_for_channel(
reply_task,
expected_revision=expected_revision,
actor=actor,
channel_endpoint_id=endpoint.id,
external_conversation_id=conversation_id,
)
except CustomerReplyError as error:
raise ChannelError(str(error)) from error
existing_reply = (
ChannelMessage.objects.select_related("conversation")
.filter(
customer_reply_task=reply_task,
reply_revision=reply_task.revision,
direction="outbound",
)
.first()
)
if existing_reply is not None:
if (
existing_reply.endpoint_id == endpoint.id
and existing_reply.conversation
and existing_reply.conversation.external_conversation_id == conversation_id
):
return existing_reply
raise ChannelError("该客户回复版本已进入其他渠道会话")
conversation, _ = ChannelConversation.objects.get_or_create(
endpoint=endpoint,
external_conversation_id=conversation_id,
)
if conversation.ticket_id and conversation.ticket_id != reply_task.ticket_id:
raise ChannelError("渠道会话已关联其他工单")
if not conversation.ticket_id:
conversation.ticket = reply_task.ticket
conversation.save(update_fields=["ticket", "updated_at"])
metadata = payload.get("payload") if isinstance(payload.get("payload"), dict) else {}
metadata = {
**metadata,
"customer_reply_task_id": reply_task.id,
"reply_revision": reply_task.revision,
}
message = ChannelMessage.objects.create(
endpoint=endpoint,
conversation=conversation,
ticket=reply_task.ticket,
customer_reply_task=reply_task,
reply_revision=reply_task.revision,
direction="outbound",
idempotency_key=idempotency_key,
sender=actor.get_full_name() or actor.username,
text=reply_task.final_text,
payload=metadata,
status="queued",
created_by=actor,
)
OperationAuditLog.objects.create(
user=actor,
module="channels",
action="customer_reply_queued",
target_type="channel_message",
target_id=str(message.public_id),
request_snapshot={
"endpoint_id": endpoint.id,
"ticket_id": reply_task.ticket_id,
"customer_reply_task_id": reply_task.id,
"reply_revision": reply_task.revision,
"idempotency_key": idempotency_key,
},
result="success",
)
def enqueue_delivery():
from apps.channels.tasks import deliver_channel_message_task
deliver_channel_message_task.delay(message.id)
transaction.on_commit(enqueue_delivery)
return message
@transaction.atomic
def record_channel_receipt(endpoint: ChannelEndpoint, payload: dict) -> ChannelReceipt:
"""幂等记录渠道回执并推进消息状态,重复回执不重复完成业务任务。"""
endpoint = ChannelEndpoint.objects.select_for_update().get(id=endpoint.id)
receipt_type = str(payload.get("receipt_type") or "").strip()
external_message_id = str(payload.get("external_message_id") or "").strip()
external_receipt_id = str(payload.get("external_receipt_id") or "").strip()
if receipt_type not in {"sent", "delivered", "read", "failed"}:
raise ChannelError("未知回执类型")
if not external_message_id or not external_receipt_id:
raise ChannelError("消息 ID 和回执 ID 不能为空")
message = ChannelMessage.objects.select_for_update().filter(
endpoint=endpoint,
direction="outbound",
external_message_id=external_message_id,
).first()
if message is None:
raise ChannelError("回执对应的出站消息不存在")
receipt, created = ChannelReceipt.objects.get_or_create(
message=message,
receipt_type=receipt_type,
external_receipt_id=external_receipt_id,
defaults={
"payload": payload.get("payload")
if isinstance(payload.get("payload"), dict)
else {}
},
)
if not created:
return receipt
if receipt_type == "failed":
message.status = "failed"
message.error_message = str(receipt.payload.get("reason") or "渠道返回失败回执")
elif receipt_type in {"delivered", "read"}:
message.status = "delivered"
message.delivered_at = timezone.now()
message.error_message = ""
elif message.status == "queued":
message.status = "sent"
message.sent_at = timezone.now()
message.save()
if message.status == "delivered":
_confirm_delivered_reply(message)
return receipt