新增微信客服、微信公众号、微信支付通知三个渠道扩展。 微信客服渠道扩展功能模块: - 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: 回调补偿
246 lines
8.0 KiB
Python
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
|