feat: add enterprise identity and channel integrations
This commit is contained in:
1
backend/apps/channels/__init__.py
Normal file
1
backend/apps/channels/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
242
backend/apps/channels/api.py
Normal file
242
backend/apps/channels/api.py
Normal 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}
|
||||
6
backend/apps/channels/apps.py
Normal file
6
backend/apps/channels/apps.py
Normal file
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class ChannelsConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "apps.channels"
|
||||
112
backend/apps/channels/migrations/0001_initial.py
Normal file
112
backend/apps/channels/migrations/0001_initial.py
Normal 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"),
|
||||
),
|
||||
]
|
||||
@@ -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",
|
||||
),
|
||||
),
|
||||
]
|
||||
1
backend/apps/channels/migrations/__init__.py
Normal file
1
backend/apps/channels/migrations/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
157
backend/apps/channels/models.py
Normal file
157
backend/apps/channels/models.py
Normal 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",
|
||||
)
|
||||
]
|
||||
86
backend/apps/channels/schemas.py
Normal file
86
backend/apps/channels/schemas.py
Normal 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
|
||||
375
backend/apps/channels/services.py
Normal file
375
backend/apps/channels/services.py
Normal 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
|
||||
8
backend/apps/channels/tasks.py
Normal file
8
backend/apps/channels/tasks.py
Normal 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
|
||||
1
backend/apps/channels/tests/__init__.py
Normal file
1
backend/apps/channels/tests/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
286
backend/apps/channels/tests/test_channel_runtime.py
Normal file
286
backend/apps/channels/tests/test_channel_runtime.py
Normal 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
|
||||
Reference in New Issue
Block a user