"""企业渠道消息的签名校验、幂等收发、回执和客户回复任务联动。""" 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