434 lines
16 KiB
Python
434 lines
16 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",
|
|
)
|
|
|
|
if message.ticket_id:
|
|
message_id = message.id
|
|
|
|
def process_ticket_feedback() -> None:
|
|
from apps.tickets.automation import handle_customer_feedback
|
|
|
|
inbound = ChannelMessage.objects.get(id=message_id)
|
|
handle_customer_feedback(
|
|
inbound.ticket_id,
|
|
inbound.text,
|
|
channel_message_id=inbound.id,
|
|
)
|
|
|
|
transaction.on_commit(process_ticket_feedback)
|
|
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)
|
|
elif message.status == "failed" and message.customer_reply_task_id:
|
|
from apps.tickets.automation import handle_customer_reply_delivery_failed
|
|
|
|
handle_customer_reply_delivery_failed(
|
|
message.customer_reply_task_id,
|
|
message.error_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 retry_outbound_message(message: ChannelMessage, *, actor=None) -> ChannelMessage:
|
|
"""重新投递同一条失败消息,复用原幂等键和冻结回复版本。"""
|
|
|
|
message = ChannelMessage.objects.select_for_update(of=("self",)).get(id=message.id)
|
|
if message.direction != "outbound" or message.status != "failed":
|
|
raise ChannelError("仅失败的出站消息可以重试")
|
|
message.status = "queued"
|
|
message.error_message = ""
|
|
message.save(update_fields=["status", "error_message", "updated_at"])
|
|
OperationAuditLog.objects.create(
|
|
user=actor,
|
|
module="channels",
|
|
action="message_delivery_retried",
|
|
target_type="channel_message",
|
|
target_id=str(message.public_id),
|
|
request_snapshot={"idempotency_key": message.idempotency_key},
|
|
result="success",
|
|
)
|
|
|
|
def enqueue_delivery() -> None:
|
|
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 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)
|
|
elif message.status == "failed" and message.customer_reply_task_id:
|
|
from apps.tickets.automation import handle_customer_reply_delivery_failed
|
|
|
|
handle_customer_reply_delivery_failed(
|
|
message.customer_reply_task_id,
|
|
message.error_message,
|
|
)
|
|
return receipt
|