refactor(wechat-mp): 重构微信公众号插件适配纯Webhook模式

1.  将transport_mode改为pull以通过manifest校验,实际仅使用Webhook接收消息
2.  统一处理XML payload兼容raw_body包装格式
3.  重构配置读取逻辑,使用CHANNEL_TARGET常量替换硬编码target
4.  移除冗余的webhook_url配置诊断项,更新文档说明
5.  优化代码格式与导入顺序
This commit is contained in:
Kris 2026-07-09 04:20:04 +08:00
parent ebbd552970
commit feee2e1166
9 changed files with 247 additions and 100 deletions

View File

@ -2,9 +2,9 @@
实现 ``DoctorAdapter`` Protocol为微信公众号渠道提供配置诊断能力 实现 ``DoctorAdapter`` Protocol为微信公众号渠道提供配置诊断能力
FR-17辅助管理员定位凭证失效IP 白名单未配置access_token 不可用 FR-17辅助管理员定位凭证失效IP 白名单未配置access_token 不可用
EncodingAESKey 错误Webhook 未配置等常见问题 EncodingAESKey 错误等常见问题
诊断检查项``getDiagnosticItems`` 声明 5 诊断检查项``getDiagnosticItems`` 声明 4
- ``app_id_app_secret_valid``CRITICAL不可自动修复校验 app_id 格式 - ``app_id_app_secret_valid``CRITICAL不可自动修复校验 app_id 格式
app_secret 非空 app_secret 非空
- ``ip_whitelist_configured``CRITICAL不可自动修复调用 - ``ip_whitelist_configured``CRITICAL不可自动修复调用
@ -13,8 +13,12 @@ EncodingAESKey 错误与 Webhook 未配置等常见问题。
``refresh_access_token`` 验证凭证有效性 ``refresh_access_token`` 验证凭证有效性
- ``encoding_aes_key_valid``ERROR不可自动修复校验 - ``encoding_aes_key_valid``ERROR不可自动修复校验
``encoding_aes_key`` 43 compatible/safe 模式 ``encoding_aes_key`` 43 compatible/safe 模式
- ``webhook_url_configured``ERROR不可自动修复校验 ``webhook_url``
非空 wechat_mp Webhook URL 由系统按
``WEBHOOK_URL_TEMPLATE`` 生成非用户配置wizard
``webhook_url_display`` 步骤已展示 URL 供用户填入微信公众平台故不设置
``webhook_url_configured`` 诊断项服务端无法验证用户是否已在微信后台
完成配置
设计要点 设计要点
- 适配器无状态statelessINV-5仅持有 DI 注入的 ``WeChatMpClient`` / - 适配器无状态statelessINV-5仅持有 DI 注入的 ``WeChatMpClient`` /
@ -55,7 +59,6 @@ _ITEM_APP_ID_APP_SECRET_VALID = "app_id_app_secret_valid"
_ITEM_IP_WHITELIST_CONFIGURED = "ip_whitelist_configured" _ITEM_IP_WHITELIST_CONFIGURED = "ip_whitelist_configured"
_ITEM_ACCESS_TOKEN_AVAILABLE = "access_token_available" _ITEM_ACCESS_TOKEN_AVAILABLE = "access_token_available"
_ITEM_ENCODING_AES_KEY_VALID = "encoding_aes_key_valid" _ITEM_ENCODING_AES_KEY_VALID = "encoding_aes_key_valid"
_ITEM_WEBHOOK_URL_CONFIGURED = "webhook_url_configured"
# EncodingAESKey 固定长度 # EncodingAESKey 固定长度
_ENCODING_AES_KEY_LENGTH = 43 _ENCODING_AES_KEY_LENGTH = 43
@ -93,18 +96,9 @@ _DIAGNOSTIC_ITEMS: tuple[DiagnosticCheck, ...] = (
description="校验 encoding_aes_key 为 43 位(仅 compatible/safe 模式)", description="校验 encoding_aes_key 为 43 位(仅 compatible/safe 模式)",
auto_repairable=False, auto_repairable=False,
), ),
DiagnosticCheck(
check_id=_ITEM_WEBHOOK_URL_CONFIGURED,
name="Webhook URL 配置",
severity=DiagnosticSeverity.ERROR,
description="校验 webhook_url 非空",
auto_repairable=False,
),
) )
_DIAGNOSTIC_ITEM_MAP: dict[str, DiagnosticCheck] = { _DIAGNOSTIC_ITEM_MAP: dict[str, DiagnosticCheck] = {item.check_id: item for item in _DIAGNOSTIC_ITEMS}
item.check_id: item for item in _DIAGNOSTIC_ITEMS
}
class WeChatMpDoctorAdapter: class WeChatMpDoctorAdapter:
@ -234,8 +228,6 @@ class WeChatMpDoctorAdapter:
return await self._run_access_token_available(account_id, check) return await self._run_access_token_available(account_id, check)
if item_id == _ITEM_ENCODING_AES_KEY_VALID: if item_id == _ITEM_ENCODING_AES_KEY_VALID:
return await self._run_encoding_aes_key_valid(account_id, check) return await self._run_encoding_aes_key_valid(account_id, check)
if item_id == _ITEM_WEBHOOK_URL_CONFIGURED:
return await self._run_webhook_url_configured(account_id, check)
raise ValidationError( raise ValidationError(
field="item_id", field="item_id",
message=f"unknown_check: {item_id}", message=f"unknown_check: {item_id}",
@ -381,10 +373,7 @@ class WeChatMpDoctorAdapter:
check_id=check.check_id, check_id=check.check_id,
passed=True, passed=True,
severity=check.severity, severity=check.severity,
message=( message=(f"encrypt_mode is '{encrypt_mode or 'plain'}', encoding_aes_key not required"),
f"encrypt_mode is '{encrypt_mode or 'plain'}', "
"encoding_aes_key not required"
),
auto_repairable=check.auto_repairable, auto_repairable=check.auto_repairable,
) )
encoding_aes_key = await self._read_config(account_id, "encoding_aes_key") encoding_aes_key = await self._read_config(account_id, "encoding_aes_key")
@ -394,8 +383,7 @@ class WeChatMpDoctorAdapter:
passed=False, passed=False,
severity=check.severity, severity=check.severity,
message=( message=(
f"encoding_aes_key must be {_ENCODING_AES_KEY_LENGTH} characters, " f"encoding_aes_key must be {_ENCODING_AES_KEY_LENGTH} characters, got {len(encoding_aes_key)}"
f"got {len(encoding_aes_key)}"
), ),
auto_repairable=check.auto_repairable, auto_repairable=check.auto_repairable,
) )
@ -407,28 +395,6 @@ class WeChatMpDoctorAdapter:
auto_repairable=check.auto_repairable, auto_repairable=check.auto_repairable,
) )
async def _run_webhook_url_configured(
self,
account_id: str,
check: DiagnosticCheck,
) -> DiagnosticResult:
webhook_url = await self._read_config(account_id, "webhook_url")
if not webhook_url:
return DiagnosticResult(
check_id=check.check_id,
passed=False,
severity=check.severity,
message="webhook_url is not configured",
auto_repairable=check.auto_repairable,
)
return DiagnosticResult(
check_id=check.check_id,
passed=True,
severity=check.severity,
message="webhook_url is configured",
auto_repairable=check.auto_repairable,
)
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# 配置读取辅助 # 配置读取辅助
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@ -436,9 +402,14 @@ class WeChatMpDoctorAdapter:
async def _read_config(self, account_id: str, key: str) -> str: async def _read_config(self, account_id: str, key: str) -> str:
from yuxi.channels.contract.dtos.config import ConfigScope from yuxi.channels.contract.dtos.config import ConfigScope
# app_id / app_secret / token / encoding_aes_key 均为 account-level
# 配置manifest config_schema scope=account使用 ConfigScope.ACCOUNT
# + target=account_id 读取,与 wecom 适配器保持一致。
try: try:
cv = await self._config.get( cv = await self._config.get(
key, scope=ConfigScope.CHANNEL, target=account_id, key,
scope=ConfigScope.ACCOUNT,
target=account_id,
) )
except Exception: except Exception:
return "" return ""

View File

@ -44,11 +44,12 @@ from yuxi.channels.contract.ports.driven.logger_port import LoggerPort
from .. import crypto from .. import crypto
from .._constants import ( from .._constants import (
CHANNEL_TARGET,
ENCRYPT_MODE_COMPATIBLE, ENCRYPT_MODE_COMPATIBLE,
ENCRYPT_MODE_PLAINTEXT, ENCRYPT_MODE_PLAINTEXT,
ENCRYPT_MODE_SAFE, ENCRYPT_MODE_SAFE,
WECHAT_MP_DEP,
WEBHOOK_SUCCESS_RESPONSE, WEBHOOK_SUCCESS_RESPONSE,
WECHAT_MP_DEP,
) )
from ..wechat_mp_client import WeChatMpClient from ..wechat_mp_client import WeChatMpClient
@ -91,15 +92,17 @@ class WeChatMpInboundAdapter:
@pre @pre
- ``raw_event.payload`` 为微信 Webhook POST XML 字符串 - ``raw_event.payload`` 为微信 Webhook POST XML 字符串
明文模式直接原文兼容/安全模式为含 ``<Encrypt>`` 的密文 XML 明文模式直接原文兼容/安全模式为含 ``<Encrypt>`` 的密文 XML
- ``raw_event.headers`` ``encrypt_mode``可选默认 plaintext 或框架包装的 ``{"raw_body": "<xml>...</xml>"}`` dict
- ``raw_event.account_id`` 由框架注入 - ``raw_event.account_id`` 由框架注入
- 加密模式 / token / encoding_aes_key / app_id 由适配器从
``ConfigPort`` 读取不依赖框架注入到 headers
@post @post
- 返回 ``MessageContent``含文本与可选附件 - 返回 ``MessageContent``含文本与可选附件
@failure @failure
- ``ValidationError``字段缺失 / 未知 MsgType / 解密失败 - ``ValidationError``字段缺失 / 未知 MsgType / 解密失败
- ``DependencyError``网络异常 - ``DependencyError``网络异常 / 配置读取失败
@consistency @consistency
- 无状态``@idempotent: True`` - 无状态``@idempotent: True``
@ -107,23 +110,28 @@ class WeChatMpInboundAdapter:
payload = raw_event.payload payload = raw_event.payload
account_id = raw_event.account_id or "" account_id = raw_event.account_id or ""
# 兼容两种 payload 形式XML 字符串 或 已解析 dict测试/框架透传) # 兼容三种 payload 形式:
# - XML 字符串(直传场景)
# - 已解析 dict测试/框架透传)
# - 框架包装 {"raw_body": "<xml>...</xml>"}(非 JSON body 经
# InboundMessageService._buildRawEvent 包装,生产 webhook 路径)
if isinstance(payload, dict): if isinstance(payload, dict):
raw_body = payload.get("raw_body")
if isinstance(raw_body, str) and raw_body:
# 框架包装的 raw_body解析 XML 字符串
xml_dict = await self._parse_payload_text(
raw_body,
account_id=account_id,
)
else:
# 已解析 dict测试/框架透传,已含 MsgType 等字段)
xml_dict = payload xml_dict = payload
else: else:
xml_text = payload if isinstance(payload, str) else str(payload) xml_text = payload if isinstance(payload, str) else str(payload)
# 解密(兼容/安全模式) xml_dict = await self._parse_payload_text(
encrypt_mode = raw_event.headers.get("encrypt_mode", ENCRYPT_MODE_PLAINTEXT)
encoding_aes_key = raw_event.headers.get("encoding_aes_key", "")
app_id = raw_event.headers.get("app_id", "")
if encrypt_mode in (ENCRYPT_MODE_COMPATIBLE, ENCRYPT_MODE_SAFE):
xml_text = self._decrypt_if_needed(
xml_text, xml_text,
encoding_aes_key=encoding_aes_key, account_id=account_id,
app_id=app_id,
encrypt_mode=encrypt_mode,
) )
xml_dict = _parse_xml_to_dict(xml_text)
msg_type = xml_dict.get("MsgType") msg_type = xml_dict.get("MsgType")
if not msg_type: if not msg_type:
@ -194,7 +202,9 @@ class WeChatMpInboundAdapter:
"""校验微信 Webhook 签名。 """校验微信 Webhook 签名。
@pre @pre
- ``raw_event.headers`` signature / timestamp / nonce / token - ``raw_event.headers`` signature / timestamp / nonce
``webhook_router`` URL query 参数合并到 headers
- ``raw_event.account_id`` 由框架注入
@post @post
- 明文模式 始终返回 ``valid=True`` - 明文模式 始终返回 ``valid=True``
@ -207,11 +217,13 @@ class WeChatMpInboundAdapter:
@consistency @consistency
- 无状态``@idempotent: True`` - 无状态``@idempotent: True``
""" """
encrypt_mode = raw_event.headers.get("encrypt_mode", ENCRYPT_MODE_PLAINTEXT) encrypt_mode = await self._get_encrypt_mode()
if encrypt_mode == ENCRYPT_MODE_PLAINTEXT: if encrypt_mode == ENCRYPT_MODE_PLAINTEXT:
return SignatureVerifyResult(valid=True) return SignatureVerifyResult(valid=True)
token = raw_event.headers.get("token", "") account_id = raw_event.account_id or ""
config = await self._get_account_config(account_id)
token = config["token"]
if not token: if not token:
raise ValidationError(field="token", message="token_required") raise ValidationError(field="token", message="token_required")
@ -224,7 +236,14 @@ class WeChatMpInboundAdapter:
# 兼容模式:明文消息无 <Encrypt>,签名为 SHA1(sort([token, timestamp, nonce]))3 参数) # 兼容模式:明文消息无 <Encrypt>,签名为 SHA1(sort([token, timestamp, nonce]))3 参数)
encrypt = "" encrypt = ""
payload = raw_event.payload payload = raw_event.payload
if isinstance(payload, str): if isinstance(payload, dict):
raw_body = payload.get("raw_body")
if isinstance(raw_body, str):
encrypt = crypto.extract_encrypt(raw_body) or ""
elif "Encrypt" in payload:
# 已解析 dict 含 Encrypt 字段(测试/框架透传场景)
encrypt = str(payload.get("Encrypt") or "")
elif isinstance(payload, str):
encrypt = crypto.extract_encrypt(payload) or "" encrypt = crypto.extract_encrypt(payload) or ""
if encrypt: if encrypt:
@ -289,6 +308,67 @@ class WeChatMpInboundAdapter:
# 内部辅助 # 内部辅助
# ------------------------------------------------------------------ # ------------------------------------------------------------------
async def _get_account_config(self, account_id: str) -> dict[str, str]:
"""读取账户级签名/解密配置。
``ConfigPort`` 读取 account-level 配置token / encoding_aes_key /
app_id wecom 适配器保持一致框架 ``_buildRawEvent`` 不注入
account-level 配置到 ``raw_event.headers``适配器必须自行从
``ConfigPort`` 读取
@failure ``account_id`` 为空或配置读取失败时抛 ``DependencyError``
"""
from yuxi.channels.contract.dtos.config import ConfigScope
if not account_id:
raise DependencyError(
dep=WECHAT_MP_DEP,
cause=ValueError("account_id missing for config resolution"),
)
result: dict[str, str] = {}
for key in ("token", "encoding_aes_key", "app_id"):
cv = await self._config.get(
key,
scope=ConfigScope.ACCOUNT,
target=account_id,
)
result[key] = str(cv.value) if cv.value else ""
return result
async def _get_encrypt_mode(self) -> str:
"""从 ConfigPort 读取 channel-level 加密模式。"""
from yuxi.channels.contract.dtos.config import ConfigScope
cv = await self._config.get(
"encrypt_mode",
scope=ConfigScope.CHANNEL,
target=CHANNEL_TARGET,
)
return str(cv.value) if cv.value else ENCRYPT_MODE_PLAINTEXT
async def _parse_payload_text(
self,
xml_text: str,
*,
account_id: str,
) -> dict[str, Any]:
"""解析 XML 文本为 dict按需先解密。
统一处理 XML 字符串场景直传与框架 ``raw_body`` 包装
``ConfigPort`` 读取 account-level 配置encoding_aes_key / app_id
channel-level 配置encrypt_mode按需解密后解析 XML
"""
encrypt_mode = await self._get_encrypt_mode()
if encrypt_mode in (ENCRYPT_MODE_COMPATIBLE, ENCRYPT_MODE_SAFE):
config = await self._get_account_config(account_id)
xml_text = self._decrypt_if_needed(
xml_text,
encoding_aes_key=config["encoding_aes_key"],
app_id=config["app_id"],
encrypt_mode=encrypt_mode,
)
return _parse_xml_to_dict(xml_text)
def _decrypt_if_needed( def _decrypt_if_needed(
self, self,
xml_text: str, xml_text: str,

View File

@ -42,6 +42,8 @@ from yuxi.channels.contract.ports.driven.config_port import ConfigPort
from yuxi.channels.contract.ports.driven.logger_port import LoggerPort from yuxi.channels.contract.ports.driven.logger_port import LoggerPort
from .._constants import ( from .._constants import (
_CACHE_INVALIDATE_PATTERN_TEMPLATE,
_CACHE_RUNTIME_INVALIDATE_PATTERN_TEMPLATE,
APP_ID_PATTERN, APP_ID_PATTERN,
DEFAULT_API_BASE_URL, DEFAULT_API_BASE_URL,
DEFAULT_HTTP_TIMEOUT_SECONDS, DEFAULT_HTTP_TIMEOUT_SECONDS,
@ -50,8 +52,6 @@ from .._constants import (
ENCRYPT_MODE_COMPATIBLE, ENCRYPT_MODE_COMPATIBLE,
ENCRYPT_MODE_PLAINTEXT, ENCRYPT_MODE_PLAINTEXT,
ENCRYPT_MODE_SAFE, ENCRYPT_MODE_SAFE,
_CACHE_INVALIDATE_PATTERN_TEMPLATE,
_CACHE_RUNTIME_INVALIDATE_PATTERN_TEMPLATE,
) )
from ..wechat_mp_client import WeChatMpClient from ..wechat_mp_client import WeChatMpClient

View File

@ -9,12 +9,20 @@
- ``group_id`` / ``topic_id`` 始终为 ``None`` - ``group_id`` / ``topic_id`` 始终为 ``None``
- ``account_id`` ``raw_event.account_id`` 提取框架已注入 - ``account_id`` ``raw_event.account_id`` 提取框架已注入
payload 形式兼容
- 已解析 dict测试/框架透传 FromUserName 等字段直接读取
- 框架包装 ``{"raw_body": "<xml>...</xml>"}`` JSON body
``InboundMessageService._buildRawEvent`` 包装解析 XML 后读取
依赖方向 import ``yuxi.channels.contract.*`` 与同插件内部 ``_constants`` 依赖方向 import ``yuxi.channels.contract.*`` 与同插件内部 ``_constants``
不污染框架层 不污染框架层
""" """
from __future__ import annotations from __future__ import annotations
import xml.etree.ElementTree as ET
from typing import Any
from yuxi.channels.contract.dtos.channel import ChannelType, SessionInfo from yuxi.channels.contract.dtos.channel import ChannelType, SessionInfo
from yuxi.channels.contract.dtos.common import RawEvent from yuxi.channels.contract.dtos.common import RawEvent
from yuxi.channels.contract.errors import ValidationError from yuxi.channels.contract.errors import ValidationError
@ -85,12 +93,48 @@ class WeChatMpSessionAdapter:
# ---------------------------------------------------------------------- # ----------------------------------------------------------------------
def _resolve_payload(payload: dict[str, Any] | str) -> dict[str, Any]:
"""解析 payload兼容框架 ``raw_body`` 包装。
- payload dict 且含 ``raw_body`` 字符串解析 XML 返回字段 dict
- payload dict 不含 ``raw_body``直接返回
- payload str解析 XML 返回字段 dict
"""
if isinstance(payload, str):
return _parse_xml_to_dict(payload)
if isinstance(payload, dict):
raw_body = payload.get("raw_body")
if isinstance(raw_body, str) and raw_body:
return _parse_xml_to_dict(raw_body)
return payload
return {}
def _parse_xml_to_dict(xml_text: str) -> dict[str, Any]:
"""将微信 XML 消息解析为扁平字典。"""
try:
root = ET.fromstring(xml_text)
except ET.ParseError as exc:
raise ValidationError(
field="payload",
message="xml_parse_failed",
) from exc
result: dict[str, Any] = {}
for child in root:
result[child.tag] = child.text or ""
return result
def _extract_from_user_name(raw_event: RawEvent) -> str: def _extract_from_user_name(raw_event: RawEvent) -> str:
"""从 ``raw_event.payload`` 提取 ``FromUserName`` 字段。 """从 ``raw_event.payload`` 提取 ``FromUserName`` 字段。
兼容框架 ``raw_body`` 包装payload ``{"raw_body": "<xml>...</xml>"}``
时先解析 XML 再提取字段
@failure ``FromUserName`` 缺失或为空抛 ``ValidationError`` @failure ``FromUserName`` 缺失或为空抛 ``ValidationError``
""" """
from_user_name = raw_event.payload.get("FromUserName", "") payload = _resolve_payload(raw_event.payload)
from_user_name = payload.get("FromUserName", "")
if not from_user_name: if not from_user_name:
raise ValidationError( raise ValidationError(
field="FromUserName", field="FromUserName",

View File

@ -16,11 +16,17 @@
- 所有事件均返回 ``StatusPayload````ref_channel_msg_id`` 使用 - 所有事件均返回 ``StatusPayload````ref_channel_msg_id`` 使用
``f"{FromUserName}_{CreateTime}"`` 构造系统事件无 ``MsgId`` ``f"{FromUserName}_{CreateTime}"`` 构造系统事件无 ``MsgId``
payload 形式兼容
- 已解析 dict测试/框架透传 MsgType 等字段直接读取
- 框架包装 ``{"raw_body": "<xml>...</xml>"}`` JSON body
``InboundMessageService._buildRawEvent`` 包装解析 XML 后读取
依赖方向 import ``yuxi.channels.contract.*``不污染框架层 依赖方向 import ``yuxi.channels.contract.*``不污染框架层
""" """
from __future__ import annotations from __future__ import annotations
import xml.etree.ElementTree as ET
from datetime import UTC, datetime from datetime import UTC, datetime
from typing import Any from typing import Any
@ -63,11 +69,12 @@ class WeChatMpStatusAdapter:
@consistency 无状态``@idempotent: True`` @consistency 无状态``@idempotent: True``
""" """
msg_type = raw_event.payload.get("MsgType", "") payload = _resolve_payload(raw_event.payload)
msg_type = payload.get("MsgType", "")
if msg_type in _MESSAGE_TYPES: if msg_type in _MESSAGE_TYPES:
return EventType.MESSAGE return EventType.MESSAGE
if msg_type == "event": if msg_type == "event":
event = raw_event.payload.get("Event", "") event = payload.get("Event", "")
if event in _SYSTEM_EVENT_TYPES: if event in _SYSTEM_EVENT_TYPES:
return EventType.MESSAGE return EventType.MESSAGE
return EventType.UNKNOWN return EventType.UNKNOWN
@ -82,11 +89,12 @@ class WeChatMpStatusAdapter:
@failure ``CreateTime`` 缺失或非数字抛 ``ValidationError(field="CreateTime")`` @failure ``CreateTime`` 缺失或非数字抛 ``ValidationError(field="CreateTime")``
@consistency 无状态``@idempotent: True`` @consistency 无状态``@idempotent: True``
""" """
payload = _resolve_payload(raw_event.payload)
event_type = await self.classifyEvent(raw_event) event_type = await self.classifyEvent(raw_event)
from_user = raw_event.payload.get("FromUserName", "") from_user = payload.get("FromUserName", "")
create_time = raw_event.payload.get("CreateTime", "") create_time = payload.get("CreateTime", "")
ref_id = f"{from_user}_{create_time}" if from_user and create_time else from_user or str(create_time) ref_id = f"{from_user}_{create_time}" if from_user and create_time else from_user or str(create_time)
timestamp = _extract_timestamp(raw_event.payload) timestamp = _extract_timestamp(payload)
return StatusPayload( return StatusPayload(
event_type=event_type, event_type=event_type,
ref_channel_msg_id=ref_id, ref_channel_msg_id=ref_id,
@ -99,6 +107,38 @@ class WeChatMpStatusAdapter:
# ---------------------------------------------------------------------- # ----------------------------------------------------------------------
def _resolve_payload(payload: dict[str, Any] | str) -> dict[str, Any]:
"""解析 payload兼容框架 ``raw_body`` 包装。
- payload dict 且含 ``raw_body`` 字符串解析 XML 返回字段 dict
- payload dict 不含 ``raw_body``直接返回
- payload str解析 XML 返回字段 dict
"""
if isinstance(payload, str):
return _parse_xml_to_dict(payload)
if isinstance(payload, dict):
raw_body = payload.get("raw_body")
if isinstance(raw_body, str) and raw_body:
return _parse_xml_to_dict(raw_body)
return payload
return {}
def _parse_xml_to_dict(xml_text: str) -> dict[str, Any]:
"""将微信 XML 消息解析为扁平字典。"""
try:
root = ET.fromstring(xml_text)
except ET.ParseError as exc:
raise ValidationError(
field="payload",
message="xml_parse_failed",
) from exc
result: dict[str, Any] = {}
for child in root:
result[child.tag] = child.text or ""
return result
def _extract_timestamp(payload: dict[str, Any]) -> datetime: def _extract_timestamp(payload: dict[str, Any]) -> datetime:
"""从 ``payload.CreateTime`` 提取时间戳。 """从 ``payload.CreateTime`` 提取时间戳。

View File

@ -41,15 +41,13 @@ client 实例化、LifecycleHandler 构造与 ``registerAdapter`` 调用。
- ``directory`` 接收 ``(client, cache_port, logger_port)``调用 - ``directory`` 接收 ``(client, cache_port, logger_port)``调用
``get_user_list`` / ``get_user_info`` 查询关注者用户信息缓存于 CachePort ``get_user_list`` / ``get_user_info`` 查询关注者用户信息缓存于 CachePort
传输管理wechat_mp 通过 ``TransportManager`` ``both`` 模式管理消息 传输管理wechat_mp 为纯 Webhook 接入渠道不注册 ``PullerAdapter`` /
接收manifest ``transport_mode: "both"`` ``StreamConnectorAdapter``决策 3manifest 声明 ``transport_mode: "pull"``
- ``WebhookWorker``优先接收微信服务器 Webhook 推送消息/事件 以通过 ``manifest_loader`` 校验仅接受 ``pull``/``stream``/``both``
端到端延迟 5s5 秒被动回复策略 实际消息接收通过 Webhook 端点 ``POST /channels/wechat_mp/webhook``
- ``PullerWorker``降级Webhook 不可用时降级为无轮询公众号无拉取 ``webhook_router`` ``InboundMessageService.receiveWebhook`` 进入入站
API此模式实际不可用仅保留契约一致性 管道端到端延迟 5s5 秒被动回复策略``TransportManager`` 找不到
两者由传输引擎通过 ``ChannelAccountOnline`` / ``ChannelAccountOffline`` puller/stream_connector 适配器时不启动传输任务不影响 Webhook 接入
领域事件触发启停``LifecycleAdapter`` ``LifecycleHandler`` 不自管理
传输任务决策 3
依赖方向 import ``yuxi.channels.contract.*`` + 标准库 + 同插件内部模块 依赖方向 import ``yuxi.channels.contract.*`` + 标准库 + 同插件内部模块
不污染框架层 不污染框架层

View File

@ -323,7 +323,7 @@ class WeChatMpLifecycleHandler:
cv = await self._config.get( cv = await self._config.get(
"token_refresh_interval_s", "token_refresh_interval_s",
scope=ConfigScope.CHANNEL, scope=ConfigScope.CHANNEL,
target="wechat_mp", target=CHANNEL_TARGET,
) )
return int(cv.value) return int(cv.value)
except Exception as exc: except Exception as exc:
@ -341,7 +341,7 @@ class WeChatMpLifecycleHandler:
cv = await self._config.get( cv = await self._config.get(
"http_timeout_ms", "http_timeout_ms",
scope=ConfigScope.CHANNEL, scope=ConfigScope.CHANNEL,
target="wechat_mp", target=CHANNEL_TARGET,
) )
return int(cv.value) / 1000.0 return int(cv.value) / 1000.0
except Exception as exc: except Exception as exc:

View File

@ -26,7 +26,7 @@
}, },
"max_message_length": 2048, "max_message_length": 2048,
"supports_credential_cloning": false, "supports_credential_cloning": false,
"transport_mode": "both", "transport_mode": "pull",
"capabilities": { "capabilities": {
"rich_message": true, "rich_message": true,
"streaming": false, "streaming": false,

View File

@ -48,6 +48,7 @@ from ._constants import (
ACCESS_TOKEN_CACHE_TTL_S, ACCESS_TOKEN_CACHE_TTL_S,
ACCESS_TOKEN_LOCK_KEY_PREFIX, ACCESS_TOKEN_LOCK_KEY_PREFIX,
ACCESS_TOKEN_LOCK_TTL_S, ACCESS_TOKEN_LOCK_TTL_S,
CHANNEL_TARGET,
DEFAULT_API_BASE_URL, DEFAULT_API_BASE_URL,
DEFAULT_HTTP_TIMEOUT_SECONDS, DEFAULT_HTTP_TIMEOUT_SECONDS,
DEFAULT_MAX_MESSAGE_LENGTH, DEFAULT_MAX_MESSAGE_LENGTH,
@ -181,7 +182,7 @@ class WeChatMpClient:
cv = await self._config.get( cv = await self._config.get(
"api_base_url", "api_base_url",
scope=ConfigScope.CHANNEL, scope=ConfigScope.CHANNEL,
target="wechat_mp", target=CHANNEL_TARGET,
) )
base_url = str(cv.value) if cv.value else DEFAULT_API_BASE_URL base_url = str(cv.value) if cv.value else DEFAULT_API_BASE_URL
return base_url.rstrip("/") or DEFAULT_API_BASE_URL return base_url.rstrip("/") or DEFAULT_API_BASE_URL
@ -200,7 +201,7 @@ class WeChatMpClient:
cv = await self._config.get( cv = await self._config.get(
"http_timeout_ms", "http_timeout_ms",
scope=ConfigScope.CHANNEL, scope=ConfigScope.CHANNEL,
target="wechat_mp", target=CHANNEL_TARGET,
) )
return int(cv.value) / 1000.0 return int(cv.value) / 1000.0
except Exception as exc: except Exception as exc:
@ -595,7 +596,9 @@ class WeChatMpClient:
) )
resp = await self._invoke_with_token_retry( resp = await self._invoke_with_token_retry(
account_id, _call, operation="send_custom_message", account_id,
_call,
operation="send_custom_message",
) )
result = self._parse_json_response(resp) result = self._parse_json_response(resp)
await self._logger.debug( await self._logger.debug(
@ -641,11 +644,16 @@ class WeChatMpClient:
params = {"access_token": access_token, "type": media_type} params = {"access_token": access_token, "type": media_type}
# multipart 上传不走 _execute_httpjson_body 会误设 Content-Type # multipart 上传不走 _execute_httpjson_body 会误设 Content-Type
return await self._execute_multipart( return await self._execute_multipart(
url, account_id=account_id, params=params, files=files, url,
account_id=account_id,
params=params,
files=files,
) )
resp = await self._invoke_with_token_retry( resp = await self._invoke_with_token_retry(
account_id, _call, operation="upload_media", account_id,
_call,
operation="upload_media",
) )
data = self._parse_json_response(resp) data = self._parse_json_response(resp)
media_id = data.get("media_id") media_id = data.get("media_id")
@ -757,7 +765,9 @@ class WeChatMpClient:
) )
resp = await self._invoke_with_token_retry( resp = await self._invoke_with_token_retry(
account_id, _call, operation="download_media", account_id,
_call,
operation="download_media",
) )
# 微信媒体下载可能返回 JSON 错误体errcode!=0或二进制流 # 微信媒体下载可能返回 JSON 错误体errcode!=0或二进制流
@ -808,7 +818,9 @@ class WeChatMpClient:
) )
resp = await self._invoke_with_token_retry( resp = await self._invoke_with_token_retry(
account_id, _call, operation="get_user_info", account_id,
_call,
operation="get_user_info",
) )
return self._parse_json_response(resp, user_id_hint=openid) return self._parse_json_response(resp, user_id_hint=openid)
@ -840,7 +852,9 @@ class WeChatMpClient:
) )
resp = await self._invoke_with_token_retry( resp = await self._invoke_with_token_retry(
account_id, _call, operation="get_user_list", account_id,
_call,
operation="get_user_list",
) )
return self._parse_json_response(resp) return self._parse_json_response(resp)
@ -863,7 +877,9 @@ class WeChatMpClient:
) )
resp = await self._invoke_with_token_retry( resp = await self._invoke_with_token_retry(
account_id, _call, operation="get_callback_ip", account_id,
_call,
operation="get_callback_ip",
) )
return self._parse_json_response(resp) return self._parse_json_response(resp)
@ -928,7 +944,7 @@ class WeChatMpClient:
cv = await self._config.get( cv = await self._config.get(
"media_cache_ttl_s", "media_cache_ttl_s",
scope=ConfigScope.CHANNEL, scope=ConfigScope.CHANNEL,
target="wechat_mp", target=CHANNEL_TARGET,
) )
ttl = int(cv.value) ttl = int(cv.value)
except Exception: except Exception:
@ -998,8 +1014,6 @@ class WeChatMpClient:
- 返回 True 表示额度充足已消费 1 - 返回 True 表示额度充足已消费 1
- 返回 False 表示额度耗尽不应发送 - 返回 False 表示额度耗尽不应发送
""" """
from ._constants import CUSTOMER_SERVICE_QUOTA_OTHER_TTL_S
quota_key = self._quota_cache_key(account_id, openid) quota_key = self._quota_cache_key(account_id, openid)
cached = await self._cache.get(quota_key) cached = await self._cache.get(quota_key)
if not isinstance(cached, Some): if not isinstance(cached, Some):
@ -1034,7 +1048,7 @@ class WeChatMpClient:
cv = await self._config.get( cv = await self._config.get(
"max_message_length", "max_message_length",
scope=ConfigScope.CHANNEL, scope=ConfigScope.CHANNEL,
target="wechat_mp", target=CHANNEL_TARGET,
) )
return int(cv.value) return int(cv.value)
except Exception as exc: except Exception as exc: