feat: add enterprise identity and channel integrations

This commit is contained in:
hailin
2026-07-27 19:03:42 +08:00
parent d93cb75c10
commit e69368a2f3
44 changed files with 5045 additions and 69 deletions

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1,242 @@
from django.db import transaction
from ninja import Router
from ninja.errors import HttpError
from apps.accounts.api import require_permission
from apps.channels.models import ChannelEndpoint, ChannelMessage
from apps.channels.schemas import (
ChannelEndpointIn,
ChannelEndpointOut,
ChannelEndpointUpdateIn,
ChannelInboundIn,
ChannelMessageOut,
ChannelOutboundIn,
ChannelReceiptIn,
)
from apps.channels.services import (
ChannelError,
ingest_inbound_message,
queue_outbound_message,
record_channel_receipt,
verify_webhook_signature,
)
from apps.modelhub.crypto import encrypt_secret
router = Router(tags=["channels"])
def serialize_endpoint(endpoint: ChannelEndpoint) -> dict:
return {
"id": endpoint.id,
"public_id": endpoint.public_id,
"name": endpoint.name,
"adapter_type": endpoint.adapter_type,
"outbound_url": endpoint.outbound_url,
"webhook_secret_set": bool(endpoint.webhook_secret_encrypted),
"outbound_token_set": bool(endpoint.outbound_token_encrypted),
"enabled": endpoint.enabled,
"created_at": endpoint.created_at,
"updated_at": endpoint.updated_at,
}
def serialize_message(message: ChannelMessage) -> dict:
return {
"public_id": message.public_id,
"endpoint_id": message.endpoint_id,
"endpoint_name": message.endpoint.name,
"conversation_public_id": (
message.conversation.public_id if message.conversation else None
),
"external_conversation_id": (
message.conversation.external_conversation_id
if message.conversation
else ""
),
"ticket_id": message.ticket_id,
"customer_reply_task_id": message.customer_reply_task_id,
"reply_revision": message.reply_revision,
"direction": message.direction,
"external_message_id": message.external_message_id,
"idempotency_key": message.idempotency_key,
"sender": message.sender,
"message_type": message.message_type,
"text": message.text,
"payload": message.payload,
"status": message.status,
"error_message": message.error_message,
"occurred_at": message.occurred_at,
"sent_at": message.sent_at,
"delivered_at": message.delivered_at,
"created_at": message.created_at,
"updated_at": message.updated_at,
}
def get_endpoint(public_id: str) -> ChannelEndpoint:
endpoint = ChannelEndpoint.objects.filter(public_id=public_id).first()
if endpoint is None:
raise HttpError(404, "渠道不存在")
return endpoint
@router.get("/endpoints", response=list[ChannelEndpointOut])
def list_channel_endpoints(request):
require_permission(request, "channel.view")
return [serialize_endpoint(item) for item in ChannelEndpoint.objects.all()]
@router.post("/endpoints", response=ChannelEndpointOut)
def create_channel_endpoint(request, payload: ChannelEndpointIn):
require_permission(request, "channel.manage")
if payload.adapter_type != "generic_webhook":
raise HttpError(400, "首期仅支持 generic_webhook 适配器")
if not payload.webhook_secret.strip():
raise HttpError(400, "Webhook 密钥不能为空")
endpoint = ChannelEndpoint.objects.create(
name=payload.name,
adapter_type=payload.adapter_type,
outbound_url=payload.outbound_url,
webhook_secret_encrypted=encrypt_secret(payload.webhook_secret),
outbound_token_encrypted=(
encrypt_secret(payload.outbound_token) if payload.outbound_token else ""
),
enabled=payload.enabled,
created_by=request.user,
)
return serialize_endpoint(endpoint)
@router.patch("/endpoints/{endpoint_id}", response=ChannelEndpointOut)
def update_channel_endpoint(
request,
endpoint_id: int,
payload: ChannelEndpointUpdateIn,
):
require_permission(request, "channel.manage")
endpoint = ChannelEndpoint.objects.filter(id=endpoint_id).first()
if endpoint is None:
raise HttpError(404, "渠道不存在")
changes = payload.dict(exclude_unset=True)
for field in ("name", "outbound_url", "enabled"):
if field in changes:
setattr(endpoint, field, changes[field])
if changes.get("webhook_secret"):
endpoint.webhook_secret_encrypted = encrypt_secret(changes["webhook_secret"])
if changes.get("outbound_token"):
endpoint.outbound_token_encrypted = encrypt_secret(changes["outbound_token"])
endpoint.save()
return serialize_endpoint(endpoint)
@router.get("/messages", response=list[ChannelMessageOut])
def list_channel_messages(
request,
endpoint_id: int | None = None,
direction: str = "",
status: str = "",
):
require_permission(request, "channel.view")
queryset = ChannelMessage.objects.select_related("endpoint", "conversation")
if endpoint_id:
queryset = queryset.filter(endpoint_id=endpoint_id)
if direction:
queryset = queryset.filter(direction=direction)
if status:
queryset = queryset.filter(status=status)
return [serialize_message(item) for item in queryset[:100]]
@router.post(
"/endpoints/{endpoint_id}/messages",
response=ChannelMessageOut,
)
def send_channel_message(request, endpoint_id: int, payload: ChannelOutboundIn):
require_permission(request, "channel.send")
require_permission(request, "ticket.reply")
endpoint = ChannelEndpoint.objects.filter(id=endpoint_id).first()
if endpoint is None:
raise HttpError(404, "渠道不存在")
try:
message = queue_outbound_message(
endpoint,
payload.dict(),
actor=request.user,
)
except ChannelError as error:
raise HttpError(400, str(error)) from error
message = ChannelMessage.objects.select_related("endpoint", "conversation").get(
id=message.id
)
return serialize_message(message)
@router.post("/messages/{public_id}/retry", response=ChannelMessageOut)
def retry_channel_message(request, public_id: str):
require_permission(request, "channel.send")
require_permission(request, "ticket.reply")
message = (
ChannelMessage.objects.select_related("endpoint", "conversation")
.filter(public_id=public_id, direction="outbound")
.first()
)
if message is None:
raise HttpError(404, "出站消息不存在")
if message.status != "failed":
raise HttpError(409, "仅失败消息可以重试")
message.status = "queued"
message.error_message = ""
message.save(update_fields=["status", "error_message", "updated_at"])
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 serialize_message(message)
@router.post(
"/webhooks/{public_id}/messages",
response=ChannelMessageOut,
auth=None,
)
def receive_channel_message(request, public_id: str, payload: ChannelInboundIn):
endpoint = get_endpoint(public_id)
try:
verify_webhook_signature(
endpoint,
request.body,
request.headers.get("X-Channel-Signature", ""),
)
except ChannelError as error:
raise HttpError(401, str(error)) from error
try:
message = ingest_inbound_message(endpoint, payload.dict())
except ChannelError as error:
raise HttpError(400, str(error)) from error
return serialize_message(
ChannelMessage.objects.select_related("endpoint", "conversation").get(
id=message.id
)
)
@router.post(
"/webhooks/{public_id}/receipts",
response={200: dict},
auth=None,
)
def receive_channel_receipt(request, public_id: str, payload: ChannelReceiptIn):
endpoint = get_endpoint(public_id)
try:
verify_webhook_signature(
endpoint,
request.body,
request.headers.get("X-Channel-Signature", ""),
)
receipt = record_channel_receipt(endpoint, payload.dict())
except ChannelError as error:
raise HttpError(400, str(error)) from error
return 200, {"status": "accepted", "receipt_id": receipt.id}

View File

@@ -0,0 +1,6 @@
from django.apps import AppConfig
class ChannelsConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "apps.channels"

View File

@@ -0,0 +1,112 @@
# Generated by Django 5.2.8 on 2026-07-27
import django.db.models.deletion
import uuid
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
("tickets", "0013_ticketsupportanalysis_applied_solution_and_more"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name="ChannelEndpoint",
fields=[
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
("public_id", models.UUIDField(default=uuid.uuid4, editable=False, unique=True)),
("name", models.CharField(max_length=120, unique=True)),
("adapter_type", models.CharField(default="generic_webhook", max_length=40)),
("outbound_url", models.URLField(blank=True, max_length=500)),
("webhook_secret_encrypted", models.TextField(blank=True)),
("outbound_token_encrypted", models.TextField(blank=True)),
("enabled", models.BooleanField(default=True)),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
("created_by", models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name="created_channel_endpoints", to=settings.AUTH_USER_MODEL)),
],
options={"ordering": ["name"]},
),
migrations.CreateModel(
name="ChannelConversation",
fields=[
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
("public_id", models.UUIDField(default=uuid.uuid4, editable=False, unique=True)),
("external_conversation_id", models.CharField(max_length=200)),
("title", models.CharField(blank=True, max_length=200)),
("participants", models.JSONField(blank=True, default=list)),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
("endpoint", models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name="conversations", to="channels.channelendpoint")),
("ticket", models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name="channel_conversations", to="tickets.ticket")),
],
options={"ordering": ["-updated_at"]},
),
migrations.CreateModel(
name="ChannelMessage",
fields=[
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
("public_id", models.UUIDField(default=uuid.uuid4, editable=False, unique=True)),
("direction", models.CharField(max_length=16)),
("external_message_id", models.CharField(blank=True, max_length=200)),
("idempotency_key", models.CharField(blank=True, max_length=200)),
("sender", models.CharField(blank=True, max_length=160)),
("message_type", models.CharField(default="text", max_length=24)),
("text", models.TextField(blank=True)),
("payload", models.JSONField(blank=True, default=dict)),
("status", models.CharField(max_length=24)),
("error_message", models.TextField(blank=True)),
("occurred_at", models.DateTimeField(blank=True, null=True)),
("sent_at", models.DateTimeField(blank=True, null=True)),
("delivered_at", models.DateTimeField(blank=True, null=True)),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
("conversation", models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name="messages", to="channels.channelconversation")),
("created_by", models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name="created_channel_messages", to=settings.AUTH_USER_MODEL)),
("endpoint", models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name="messages", to="channels.channelendpoint")),
("ticket", models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name="channel_messages", to="tickets.ticket")),
],
options={"ordering": ["-created_at"]},
),
migrations.CreateModel(
name="ChannelReceipt",
fields=[
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
("receipt_type", models.CharField(max_length=24)),
("external_receipt_id", models.CharField(max_length=200)),
("payload", models.JSONField(blank=True, default=dict)),
("received_at", models.DateTimeField(auto_now_add=True)),
("message", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name="receipts", to="channels.channelmessage")),
],
options={"ordering": ["received_at"]},
),
migrations.AddConstraint(
model_name="channelconversation",
constraint=models.UniqueConstraint(fields=("endpoint", "external_conversation_id"), name="unique_channel_external_conversation"),
),
migrations.AddIndex(
model_name="channelmessage",
index=models.Index(fields=["endpoint", "direction", "created_at"], name="channels_ch_endpoin_30d460_idx"),
),
migrations.AddIndex(
model_name="channelmessage",
index=models.Index(fields=["status", "created_at"], name="channels_ch_status_eeefec_idx"),
),
migrations.AddConstraint(
model_name="channelmessage",
constraint=models.UniqueConstraint(condition=models.Q(("external_message_id__gt", "")), fields=("endpoint", "direction", "external_message_id"), name="unique_channel_external_message"),
),
migrations.AddConstraint(
model_name="channelmessage",
constraint=models.UniqueConstraint(condition=models.Q(("idempotency_key__gt", "")), fields=("endpoint", "direction", "idempotency_key"), name="unique_channel_message_idempotency"),
),
migrations.AddConstraint(
model_name="channelreceipt",
constraint=models.UniqueConstraint(fields=("message", "receipt_type", "external_receipt_id"), name="unique_channel_message_receipt"),
),
]

View File

@@ -0,0 +1,40 @@
import django.db.models.deletion
from django.db import migrations, models
from django.db.models import Q
class Migration(migrations.Migration):
dependencies = [
("channels", "0001_initial"),
("tickets", "0014_customerreplytask_channel_approval"),
]
operations = [
migrations.AddField(
model_name="channelmessage",
name="customer_reply_task",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.PROTECT,
related_name="channel_messages",
to="tickets.customerreplytask",
),
),
migrations.AddField(
model_name="channelmessage",
name="reply_revision",
field=models.PositiveIntegerField(blank=True, null=True),
),
migrations.AddConstraint(
model_name="channelmessage",
constraint=models.UniqueConstraint(
fields=("customer_reply_task", "reply_revision"),
condition=Q(
direction="outbound",
customer_reply_task__isnull=False,
),
name="unique_channel_reply_revision",
),
),
]

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1,157 @@
import uuid
from django.conf import settings
from django.db import models
from django.db.models import Q
class ChannelEndpoint(models.Model):
public_id = models.UUIDField(default=uuid.uuid4, unique=True, editable=False)
name = models.CharField(max_length=120, unique=True)
adapter_type = models.CharField(max_length=40, default="generic_webhook")
outbound_url = models.URLField(max_length=500, blank=True)
webhook_secret_encrypted = models.TextField(blank=True)
outbound_token_encrypted = models.TextField(blank=True)
enabled = models.BooleanField(default=True)
created_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="created_channel_endpoints",
)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
ordering = ["name"]
class ChannelConversation(models.Model):
public_id = models.UUIDField(default=uuid.uuid4, unique=True, editable=False)
endpoint = models.ForeignKey(
ChannelEndpoint,
on_delete=models.PROTECT,
related_name="conversations",
)
external_conversation_id = models.CharField(max_length=200)
title = models.CharField(max_length=200, blank=True)
participants = models.JSONField(default=list, blank=True)
ticket = models.ForeignKey(
"tickets.Ticket",
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="channel_conversations",
)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
ordering = ["-updated_at"]
constraints = [
models.UniqueConstraint(
fields=["endpoint", "external_conversation_id"],
name="unique_channel_external_conversation",
)
]
class ChannelMessage(models.Model):
public_id = models.UUIDField(default=uuid.uuid4, unique=True, editable=False)
endpoint = models.ForeignKey(
ChannelEndpoint,
on_delete=models.PROTECT,
related_name="messages",
)
conversation = models.ForeignKey(
ChannelConversation,
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="messages",
)
ticket = models.ForeignKey(
"tickets.Ticket",
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="channel_messages",
)
customer_reply_task = models.ForeignKey(
"tickets.CustomerReplyTask",
null=True,
blank=True,
on_delete=models.PROTECT,
related_name="channel_messages",
)
reply_revision = models.PositiveIntegerField(null=True, blank=True)
direction = models.CharField(max_length=16)
external_message_id = models.CharField(max_length=200, blank=True)
idempotency_key = models.CharField(max_length=200, blank=True)
sender = models.CharField(max_length=160, blank=True)
message_type = models.CharField(max_length=24, default="text")
text = models.TextField(blank=True)
payload = models.JSONField(default=dict, blank=True)
status = models.CharField(max_length=24)
error_message = models.TextField(blank=True)
created_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="created_channel_messages",
)
occurred_at = models.DateTimeField(null=True, blank=True)
sent_at = models.DateTimeField(null=True, blank=True)
delivered_at = models.DateTimeField(null=True, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
ordering = ["-created_at"]
constraints = [
models.UniqueConstraint(
fields=["endpoint", "direction", "external_message_id"],
condition=Q(external_message_id__gt=""),
name="unique_channel_external_message",
),
models.UniqueConstraint(
fields=["endpoint", "direction", "idempotency_key"],
condition=Q(idempotency_key__gt=""),
name="unique_channel_message_idempotency",
),
models.UniqueConstraint(
fields=["customer_reply_task", "reply_revision"],
condition=Q(
direction="outbound",
customer_reply_task__isnull=False,
),
name="unique_channel_reply_revision",
),
]
indexes = [
models.Index(fields=["endpoint", "direction", "created_at"]),
models.Index(fields=["status", "created_at"]),
]
class ChannelReceipt(models.Model):
message = models.ForeignKey(
ChannelMessage,
on_delete=models.CASCADE,
related_name="receipts",
)
receipt_type = models.CharField(max_length=24)
external_receipt_id = models.CharField(max_length=200)
payload = models.JSONField(default=dict, blank=True)
received_at = models.DateTimeField(auto_now_add=True)
class Meta:
ordering = ["received_at"]
constraints = [
models.UniqueConstraint(
fields=["message", "receipt_type", "external_receipt_id"],
name="unique_channel_message_receipt",
)
]

View File

@@ -0,0 +1,86 @@
from datetime import datetime
from typing import Any
from uuid import UUID
from ninja import Schema
class ChannelEndpointIn(Schema):
name: str
adapter_type: str = "generic_webhook"
outbound_url: str = ""
webhook_secret: str
outbound_token: str = ""
enabled: bool = True
class ChannelEndpointOut(Schema):
id: int
public_id: UUID
name: str
adapter_type: str
outbound_url: str
webhook_secret_set: bool
outbound_token_set: bool
enabled: bool
created_at: datetime
updated_at: datetime
class ChannelEndpointUpdateIn(Schema):
name: str | None = None
outbound_url: str | None = None
webhook_secret: str | None = None
outbound_token: str | None = None
enabled: bool | None = None
class ChannelInboundIn(Schema):
external_message_id: str
external_conversation_id: str
text: str
sender: str = ""
title: str = ""
participants: list[str] = []
occurred_at: datetime | None = None
payload: dict[str, Any] = {}
class ChannelReceiptIn(Schema):
external_message_id: str
external_receipt_id: str
receipt_type: str
payload: dict[str, Any] = {}
class ChannelOutboundIn(Schema):
external_conversation_id: str
idempotency_key: str
customer_reply_task_id: int
expected_revision: int
payload: dict[str, Any] = {}
class ChannelMessageOut(Schema):
public_id: UUID
endpoint_id: int
endpoint_name: str
conversation_public_id: UUID | None
external_conversation_id: str
ticket_id: int | None
customer_reply_task_id: int | None
reply_revision: int | None
direction: str
external_message_id: str
idempotency_key: str
sender: str
message_type: str
text: str
payload: dict[str, Any]
status: str
error_message: str
occurred_at: datetime | None
sent_at: datetime | None
delivered_at: datetime | None
created_at: datetime
updated_at: datetime

View File

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

View File

@@ -0,0 +1,8 @@
from celery import shared_task
from apps.channels.services import deliver_outbound_message
@shared_task(name="apps.channels.tasks.deliver_channel_message")
def deliver_channel_message_task(message_id: int):
return deliver_outbound_message(message_id).status

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1,286 @@
import hashlib
import hmac
import json
import pytest
from django.contrib.auth import get_user_model
from apps.audit.models import OperationAuditLog
from apps.channels.models import ChannelEndpoint, ChannelMessage, ChannelReceipt
from apps.channels.services import (
ChannelError,
queue_outbound_message,
record_channel_receipt,
)
from apps.modelhub.crypto import encrypt_secret
from apps.tickets.models import CustomerReplyTask, Ticket
from apps.workitems.models import WorkItem
class FakeResponse:
def __init__(self, payload: dict):
self.payload = payload
def __enter__(self):
return self
def __exit__(self, exc_type, exc_value, traceback):
return False
def read(self):
return json.dumps(self.payload).encode()
def signature(secret: str, body: bytes) -> str:
digest = hmac.new(secret.encode(), body, hashlib.sha256).hexdigest()
return f"sha256={digest}"
@pytest.mark.django_db
def test_signed_inbound_webhook_is_idempotent(client):
secret = "channel-inbound-secret"
endpoint = ChannelEndpoint.objects.create(
name="客服群",
webhook_secret_encrypted=encrypt_secret(secret),
)
body = json.dumps(
{
"external_message_id": "msg-in-001",
"external_conversation_id": "group-001",
"text": "生产环境登录失败,错误码 AUTH-401",
"sender": "客户甲",
"participants": ["客户甲", "技术支持"],
"payload": {"source": "group"},
},
ensure_ascii=False,
separators=(",", ":"),
).encode()
headers = {"HTTP_X_CHANNEL_SIGNATURE": signature(secret, body)}
first = client.post(
f"/api/channels/webhooks/{endpoint.public_id}/messages",
data=body,
content_type="application/json",
**headers,
)
second = client.post(
f"/api/channels/webhooks/{endpoint.public_id}/messages",
data=body,
content_type="application/json",
**headers,
)
assert first.status_code == 200
assert second.status_code == 200
assert first.json()["public_id"] == second.json()["public_id"]
assert ChannelMessage.objects.count() == 1
message = ChannelMessage.objects.get()
assert message.status == "received"
assert message.conversation.external_conversation_id == "group-001"
assert OperationAuditLog.objects.filter(
module="channels",
action="message_received",
target_id=str(message.public_id),
).count() == 1
@pytest.mark.django_db
def test_inbound_webhook_rejects_invalid_signature(client):
endpoint = ChannelEndpoint.objects.create(
name="签名渠道",
webhook_secret_encrypted=encrypt_secret("correct-secret"),
)
response = client.post(
f"/api/channels/webhooks/{endpoint.public_id}/messages",
data={
"external_message_id": "msg-invalid",
"external_conversation_id": "group-invalid",
"text": "不应被接收",
},
content_type="application/json",
HTTP_X_CHANNEL_SIGNATURE="sha256=invalid",
)
assert response.status_code == 401
assert ChannelMessage.objects.count() == 0
@pytest.mark.django_db(transaction=True)
def test_outbound_delivery_and_receipt_are_idempotent(
admin_client,
django_capture_on_commit_callbacks,
monkeypatch,
):
actor = get_user_model().objects.filter(is_superuser=True).first()
assert actor is not None
endpoint = ChannelEndpoint.objects.create(
name="出站渠道",
outbound_url="https://channel.example/messages",
webhook_secret_encrypted=encrypt_secret("receipt-secret"),
outbound_token_encrypted=encrypt_secret("outbound-token"),
)
ticket = Ticket.objects.create(
ticket_no="TK-CHANNEL-0001",
title="生产环境登录失败",
description="生产环境登录失败,错误码 AUTH-401",
status="pending",
)
reply_task = CustomerReplyTask.objects.create(
ticket=ticket,
status="draft",
draft_text="已收到问题,正在处理。",
final_text="已收到问题,正在处理。",
created_by=actor,
)
requests = []
def fake_urlopen(request, timeout):
requests.append((request, timeout))
return FakeResponse(
{
"status": "sent",
"external_message_id": "external-out-001",
}
)
monkeypatch.setattr("apps.channels.services.urlopen", fake_urlopen)
payload = {
"external_conversation_id": "group-002",
"idempotency_key": "reply-001",
"customer_reply_task_id": reply_task.id,
"expected_revision": reply_task.revision,
"payload": {"template": "ack"},
}
with django_capture_on_commit_callbacks(execute=True):
first = queue_outbound_message(endpoint, payload, actor=actor)
with django_capture_on_commit_callbacks(execute=True):
second = queue_outbound_message(endpoint, payload, actor=actor)
first.refresh_from_db()
reply_task.refresh_from_db()
ticket.refresh_from_db()
assert first.id == second.id
assert first.status == "sent"
assert first.external_message_id == "external-out-001"
assert first.customer_reply_task == reply_task
assert first.reply_revision == 1
assert first.text == reply_task.final_text
assert reply_task.status == "approved"
assert reply_task.approved_by == actor
assert reply_task.approved_at is not None
assert ticket.replied_to_customer is False
assert ChannelMessage.objects.filter(direction="outbound").count() == 1
assert len(requests) == 1
request, timeout = requests[0]
assert timeout == 30
assert request.get_header("Idempotency-key") == "reply-001"
assert request.get_header("Authorization") == "Bearer outbound-token"
receipt_payload = {
"external_message_id": "external-out-001",
"external_receipt_id": "receipt-001",
"receipt_type": "delivered",
"payload": {"provider_status": "delivered"},
}
first_receipt = record_channel_receipt(endpoint, receipt_payload)
second_receipt = record_channel_receipt(endpoint, receipt_payload)
first.refresh_from_db()
reply_task.refresh_from_db()
ticket.refresh_from_db()
assert first_receipt.id == second_receipt.id
assert first.status == "delivered"
assert first.delivered_at is not None
assert reply_task.status == "sent"
assert reply_task.sent_at is not None
assert ticket.replied_to_customer is True
assert WorkItem.objects.filter(
ticket=ticket,
task_type="ticket_customer_reply",
status="completed",
).count() == 1
assert ChannelReceipt.objects.count() == 1
@pytest.mark.django_db
def test_customer_reply_version_cannot_be_queued_to_another_conversation(admin_client):
actor = get_user_model().objects.get(is_superuser=True)
endpoint = ChannelEndpoint.objects.create(
name="唯一回复渠道",
outbound_url="https://channel.example/messages",
)
ticket = Ticket.objects.create(
ticket_no="TK-CHANNEL-0002",
title="接口超时",
description="生产接口持续超时",
status="pending",
)
reply_task = CustomerReplyTask.objects.create(
ticket=ticket,
draft_text="已定位到上游依赖超时。",
final_text="已定位到上游依赖超时。",
created_by=actor,
)
first_payload = {
"external_conversation_id": "group-a",
"idempotency_key": "reply-version-a",
"customer_reply_task_id": reply_task.id,
"expected_revision": 1,
}
queue_outbound_message(endpoint, first_payload, actor=actor)
with pytest.raises(ChannelError, match="已进入其他渠道会话"):
queue_outbound_message(
endpoint,
{
**first_payload,
"external_conversation_id": "group-b",
"idempotency_key": "reply-version-b",
},
actor=actor,
)
@pytest.mark.django_db
def test_outbound_api_rejects_arbitrary_text(admin_client):
endpoint = ChannelEndpoint.objects.create(
name="受控发送渠道",
outbound_url="https://channel.example/messages",
)
response = admin_client.post(
f"/api/channels/endpoints/{endpoint.id}/messages",
{
"external_conversation_id": "group-uncontrolled",
"idempotency_key": "uncontrolled-001",
"ticket_id": 1,
"text": "绕过客户回复任务直接发送",
},
content_type="application/json",
)
assert response.status_code == 422
assert ChannelMessage.objects.count() == 0
@pytest.mark.django_db
def test_channel_management_api_hides_credentials(admin_client):
response = admin_client.post(
"/api/channels/endpoints",
{
"name": "治理渠道",
"outbound_url": "https://channel.example/send",
"webhook_secret": "webhook-secret",
"outbound_token": "outbound-token",
},
content_type="application/json",
)
assert response.status_code == 200
payload = response.json()
assert payload["webhook_secret_set"] is True
assert payload["outbound_token_set"] is True
assert "webhook_secret" not in payload
assert "outbound_token" not in payload