ForcePilot/backend/package/yuxi/channel/extensions/aliyun_sms/webhook.py
Kris b8864ac12f feat(aliyun-sms): 新增阿里云短信渠道插件
实现完整的阿里云短信服务接入能力,包含短信发送、状态回执处理、上行消息接收、安全策略校验、会话管理等功能
2026-05-21 10:39:43 +08:00

214 lines
8.7 KiB
Python

from __future__ import annotations
import asyncio
import json
import logging
from fastapi import APIRouter, Request, Response
from .dedupe import AliyunSmsDeduplicator
from .format import mask_phone
from .monitor import AliyunSmsMonitor
from .signature import verify_callback_signature
from .types import InboundSmsMessage, SmsReportMessage, TemplateSmsReport
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/webhook/aliyun-sms", tags=["aliyun-sms"])
class AliyunSmsWebhookHandler:
def __init__(self, gateway):
self._gateway = gateway
self._monitor = AliyunSmsMonitor()
self._mo_dedupe = AliyunSmsDeduplicator(max_size=10000, ttl=86400)
self._report_dedupe = AliyunSmsDeduplicator(max_size=50000, ttl=86400)
async def handle_mo_callback(self, request: Request) -> Response:
raw_body = await request.body()
account = self._gateway.account
if account.access_key_secret:
if not verify_callback_signature(raw_body, request.headers, account.access_key_secret):
logger.warning("阿里云短信 MO 回调签名验证失败: account=%s", account.account_id)
return Response(content='{"code":"signature_invalid"}', media_type="application/json", status_code=403)
asyncio.create_task(self._process_mo_messages(raw_body))
return Response(content='{"code":"ok"}', media_type="application/json", status_code=200)
async def handle_report_callback(self, request: Request) -> Response:
raw_body = await request.body()
account = self._gateway.account
if account.access_key_secret:
if not verify_callback_signature(raw_body, request.headers, account.access_key_secret):
logger.warning("阿里云短信 回执回调签名验证失败: account=%s", account.account_id)
return Response(content='{"code":"signature_invalid"}', media_type="application/json", status_code=403)
asyncio.create_task(self._process_report_messages(raw_body))
return Response(content='{"code":"ok"}', media_type="application/json", status_code=200)
async def handle_template_report_callback(self, request: Request) -> Response:
raw_body = await request.body()
account = self._gateway.account
if account.access_key_secret:
if not verify_callback_signature(raw_body, request.headers, account.access_key_secret):
logger.warning("阿里云模板审核回调签名验证失败: account=%s", account.account_id)
return Response(content='{"code":"signature_invalid"}', media_type="application/json", status_code=403)
asyncio.create_task(self._process_template_report(raw_body))
return Response(content='{"code":"ok"}', media_type="application/json", status_code=200)
async def _process_template_report(self, raw_body: bytes):
try:
data = json.loads(raw_body)
except json.JSONDecodeError as e:
logger.error("阿里云模板审核回调 JSON 解析失败: %s", e)
return
messages = data if isinstance(data, list) else [data]
for msg_data in messages:
report = TemplateSmsReport(
template_code=msg_data.get("template_code", ""),
template_status=msg_data.get("template_status", ""),
reject_reason=msg_data.get("reject_reason", ""),
create_date=msg_data.get("create_date", ""),
)
status_info = "通过" if report.template_status == "approved" else f"驳回({report.reject_reason})"
logger.info(
"阿里云模板审核状态变更: template_code=%s status=%s",
report.template_code,
status_info,
)
async def _process_mo_messages(self, raw_body: bytes):
try:
data = json.loads(raw_body)
except json.JSONDecodeError as e:
logger.error("阿里云短信 MO 回调 JSON 解析失败: %s", e)
return
messages = data if isinstance(data, list) else [data]
for msg_data in messages:
seq_id = msg_data.get("sequence_id", 0)
dedupe_key = str(seq_id)
if self._mo_dedupe.is_duplicate(dedupe_key):
logger.debug("阿里云短信 MO 消息重复: sequence_id=%s", seq_id)
continue
self._mo_dedupe.mark_seen(dedupe_key)
inbound = InboundSmsMessage(
dest_code=msg_data.get("dest_code", ""),
send_time=msg_data.get("send_time", ""),
content=msg_data.get("content", ""),
sign_name=msg_data.get("sign_name", ""),
sequence_id=seq_id,
phone_number=msg_data.get("phone_number", ""),
extended_code=msg_data.get("extended_code", ""),
)
queue = self._gateway.queue
session_manager = self._gateway.session_manager
session = session_manager.resolve_session(inbound.phone_number)
session_id = session.session_id
await queue.put(
{
"channel_type": "aliyun-sms",
"account_id": self._gateway.account.account_id,
"message": inbound,
"message_type": "mo",
"session_id": session_id,
}
)
logger.debug(
"阿里云短信 MO 消息已入队: phone=%s seq_id=%s",
mask_phone(inbound.phone_number, self._gateway.account.phone_mask_enabled),
seq_id,
)
unified = self._monitor.convert_to_unified(inbound)
logger.debug("阿里云短信 MO 转换为 UnifiedMessage: %s", json.dumps(unified, ensure_ascii=False))
async def _process_report_messages(self, raw_body: bytes):
try:
data = json.loads(raw_body)
except json.JSONDecodeError as e:
logger.error("阿里云短信 回执回调 JSON 解析失败: %s", e)
return
messages = data if isinstance(data, list) else [data]
for msg_data in messages:
biz_id = msg_data.get("biz_id", "")
report_key = f"{biz_id}_{msg_data.get('report_time', '')}"
if self._report_dedupe.is_duplicate(report_key):
continue
self._report_dedupe.mark_seen(report_key)
report = SmsReportMessage(
phone_number=msg_data.get("phone_number", ""),
send_time=msg_data.get("send_time", ""),
report_time=msg_data.get("report_time", ""),
success=msg_data.get("success", False),
err_code=msg_data.get("err_code", ""),
err_msg=msg_data.get("err_msg", ""),
biz_id=biz_id,
out_id=msg_data.get("out_id", ""),
sms_size=msg_data.get("sms_size", 1),
)
if report.success:
logger.info(
"短信送达: phone=%s biz_id=%s",
mask_phone(report.phone_number, self._gateway.account.phone_mask_enabled),
biz_id,
)
else:
logger.warning(
"短信发送失败: phone=%s biz_id=%s err=%s",
mask_phone(report.phone_number, self._gateway.account.phone_mask_enabled),
biz_id,
report.err_code,
)
_sms_webhook_handlers: dict[str, AliyunSmsWebhookHandler] = {}
def register_webhook_handler(handler: AliyunSmsWebhookHandler):
_sms_webhook_handlers[handler._gateway.account.account_id] = handler
def unregister_webhook_handler(account_id: str):
_sms_webhook_handlers.pop(account_id, None)
@router.post("/callback")
async def aliyun_sms_mo_callback(request: Request):
if not _sms_webhook_handlers:
return Response(content='{"code":"handler not available"}', media_type="application/json", status_code=503)
return await _sms_webhook_handlers["default"].handle_mo_callback(request)
@router.post("/report")
async def aliyun_sms_report_callback(request: Request):
if not _sms_webhook_handlers:
return Response(content='{"code":"handler not available"}', media_type="application/json", status_code=503)
return await _sms_webhook_handlers["default"].handle_report_callback(request)
@router.post("/template-report")
async def aliyun_sms_template_report_callback(request: Request):
if not _sms_webhook_handlers:
return Response(content='{"code":"handler not available"}', media_type="application/json", status_code=503)
return await _sms_webhook_handlers["default"].handle_template_report_callback(request)