ForcePilot/backend/package/yuxi/channel/extensions/wechatpay_notify/webhook.py
Kris 87a8931db3 feat(channel): 添加微信客服、微信公众号和微信支付通知渠道扩展
新增微信客服、微信公众号、微信支付通知三个渠道扩展。

微信客服渠道扩展功能模块:
- account: 账户管理
- config: 渠道配置管理
- gateway: SSE/WebSocket 网关接入
- webhook: Webhook 事件处理
- outbound: 外发消息管理
- streaming: 流式消息处理
- pairing: 用户配对与绑定
- security: 安全校验
- crypto: 加解密处理
- dedupe: 消息去重
- customer: 客户管理
- servicer: 客服管理
- session: 会话管理
- status: 会话状态管理
- media: 媒体资源处理
- statistics: 统计功能
- sync: 数据同步
- upgrade: 升级处理

微信公众号渠道扩展功能模块:
- config: 渠道配置管理
- gateway: SSE/WebSocket 网关接入
- webhook: Webhook 事件处理
- outbound: 外发消息管理
- streaming: 流式消息处理
- pairing: 用户配对与绑定
- security: 安全校验
- crypto: 加解密处理
- dedupe: 消息去重
- passive_reply: 被动回复
- message: 消息处理
- broadcast: 群发消息
- template: 模板消息
- menu: 菜单管理
- qrcode: 二维码管理
- user: 用户管理
- media: 媒体资源处理
- status: 会话状态管理

微信支付通知渠道扩展功能模块:
- config: 渠道配置管理
- webhook: Webhook 事件处理
- crypto: 加解密与签名校验
- cert_manager: 证书管理
- event_router: 事件路由
- dedupe: 消息去重
- pay_repo: 支付数据仓库
- query_client: 查询客户端
- arq_tasks: 异步任务
- callback_compensator: 回调补偿
2026-05-21 12:00:30 +08:00

246 lines
8.0 KiB
Python

from __future__ import annotations
import asyncio
import json
import logging
import os
from typing import TYPE_CHECKING
from fastapi import APIRouter, Header, Request
from fastapi.responses import JSONResponse
from .crypto import DecryptionError
from .dedupe import get_pay_deduplicator
from .types import KNOWN_EVENT_TYPES, CallbackPayload
if TYPE_CHECKING:
from . import WechatPayNotifyPlugin
logger = logging.getLogger(__name__)
MAX_BODY_BYTES = 64 * 1024
BODY_TIMEOUT_S = 3.0
router = APIRouter(prefix="/webhook/wechatpay", tags=["wechatpay-notify"])
_plugin: WechatPayNotifyPlugin | None = None
def set_plugin(plugin: WechatPayNotifyPlugin) -> None:
global _plugin
_plugin = plugin
def clear_plugin() -> None:
global _plugin
_plugin = None
@router.get("/callback")
@router.head("/callback")
async def wechatpay_callback_health():
plugin = _plugin
if plugin is None or plugin._account is None or plugin._crypto is None:
return JSONResponse(
content={"code": "FAIL", "message": "服务未就绪"},
status_code=503,
)
return JSONResponse(
content={"code": "SUCCESS", "message": "ok"},
status_code=200,
)
@router.post("/callback")
async def wechatpay_callback(
request: Request,
wechatpay_signature: str = Header("", alias="Wechatpay-Signature"),
wechatpay_serial: str = Header("", alias="Wechatpay-Serial"),
wechatpay_timestamp: str = Header("", alias="Wechatpay-Timestamp"),
wechatpay_nonce: str = Header("", alias="Wechatpay-Nonce"),
wechatpay_signature_type: str = Header("", alias="Wechatpay-Signature-Type"),
):
plugin = _plugin
if plugin is None or plugin._account is None or plugin._crypto is None:
return JSONResponse(
content={"code": "FAIL", "message": "服务未就绪"},
status_code=503,
)
account = plugin._account
crypto = plugin._crypto
cert_mgr = plugin._cert_mgr
if wechatpay_signature_type and wechatpay_signature_type != "WECHATPAY2-SHA256-RSA2048":
logger.warning("WechatPay callback: unsupported signature type=%s", wechatpay_signature_type)
return JSONResponse(
content={"code": "FAIL", "message": "不支持的签名类型"},
status_code=400,
)
try:
body = await asyncio.wait_for(request.body(), timeout=BODY_TIMEOUT_S)
except TimeoutError:
logger.warning("WechatPay callback: body read timeout")
return JSONResponse(
content={"code": "FAIL", "message": "读取超时"},
status_code=408,
)
if len(body) > MAX_BODY_BYTES:
logger.warning("WechatPay callback: body too large (%d bytes)", len(body))
return JSONResponse(
content={"code": "FAIL", "message": "请求体过大"},
status_code=413,
)
if account.cert_mode == "public_key":
if wechatpay_serial != account.public_key_id:
logger.warning("WechatPay callback: public key id mismatch, serial=%s", wechatpay_serial)
return JSONResponse(
content={"code": "FAIL", "message": "公钥未找到"},
status_code=500,
)
sig_valid = crypto.verify_signature_with_raw_key(
body=body,
signature=wechatpay_signature,
timestamp=wechatpay_timestamp,
nonce=wechatpay_nonce,
public_key_pem=account.public_key_pem,
)
else:
if not cert_mgr:
return JSONResponse(
content={"code": "FAIL", "message": "证书管理器未就绪"},
status_code=503,
)
platform_cert_pem = await cert_mgr.get_public_key(wechatpay_serial)
if not platform_cert_pem:
logger.warning("WechatPay callback: platform cert not found for serial=%s", wechatpay_serial)
asyncio.create_task(cert_mgr.refresh(), name=f"cert-refresh-{wechatpay_serial}")
return JSONResponse(
content={"code": "FAIL", "message": "平台证书暂不可用,请重试"},
status_code=500,
)
sig_valid = crypto.verify_signature(
body=body,
signature=wechatpay_signature,
timestamp=wechatpay_timestamp,
nonce=wechatpay_nonce,
serial_no=wechatpay_serial,
platform_cert_pem=platform_cert_pem,
)
if not sig_valid:
logger.warning("WechatPay callback: signature verification failed for serial=%s", wechatpay_serial)
return JSONResponse(
content={"code": "FAIL", "message": "签名验证失败"},
status_code=401,
)
body_str = body.decode("utf-8")
payload = CallbackPayload.from_json(body_str)
if payload is None:
logger.warning("WechatPay callback: invalid JSON body")
return JSONResponse(
content={"code": "FAIL", "message": "请求格式错误"},
status_code=400,
)
if payload.event_type not in KNOWN_EVENT_TYPES:
logger.info("WechatPay callback: unknown event_type=%s, acknowledged", payload.event_type)
return JSONResponse(
content={"code": "SUCCESS", "message": "已确认(未知事件类型)"},
status_code=200,
)
dedup = get_pay_deduplicator()
is_first = await dedup.try_claim(payload.notification_id)
if not is_first:
logger.debug("WechatPay callback: duplicate notification id=%s", payload.notification_id)
return JSONResponse(
content={"code": "SUCCESS", "message": "已确认(重复通知)"},
status_code=200,
)
try:
decrypted_str = crypto.decrypt_resource(
ciphertext=payload.resource.ciphertext,
nonce=payload.resource.nonce,
associated_data=payload.resource.associated_data,
)
decrypted_data = json.loads(decrypted_str)
except DecryptionError:
logger.warning("WechatPay callback: decryption failed for notification id=%s", payload.notification_id)
return JSONResponse(
content={"code": "FAIL", "message": "解密失败"},
status_code=500,
)
asyncio.create_task(
_dispatch_event(
mch_id=account.mch_id,
event_type=payload.event_type,
notification_id=payload.notification_id,
resource_data=decrypted_data,
),
name=f"wechatpay-dispatch-{payload.notification_id}",
)
return JSONResponse(
content={"code": "SUCCESS", "message": "成功"},
status_code=200,
)
async def _dispatch_event(
mch_id: str,
event_type: str,
notification_id: str,
resource_data: dict,
) -> None:
try:
arq_pool = _get_arq_pool()
if arq_pool:
await arq_pool.enqueue_job(
"process_wechatpay_notify",
mch_id=mch_id,
event_type=event_type,
notification_id=notification_id,
resource_data=resource_data,
)
else:
from .event_router import handle_pay_event
await handle_pay_event(mch_id, event_type, notification_id, resource_data)
except Exception:
logger.exception("WechatPay dispatch error for id=%s", notification_id)
_ARQ_POOL = None
def _get_arq_pool():
global _ARQ_POOL
if _ARQ_POOL is not None:
return _ARQ_POOL
redis_url = os.environ.get("YUXI_REDIS_URL", os.environ.get("REDIS_URL", ""))
if not redis_url:
return None
try:
import arq
from arq.connections import RedisSettings
settings = RedisSettings.from_dsn(redis_url)
_ARQ_POOL = arq.ArqRedis(settings)
logger.info("WechatPay ARQ pool created: redis=%s", redis_url)
return _ARQ_POOL
except ImportError:
logger.warning("WechatPay: arq not installed, notifications will process synchronously")
return None
except Exception:
logger.exception("WechatPay: failed to create ARQ pool")
return None