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)