From eda29502ed3cad766d49683eeab46e9c0479a724 Mon Sep 17 00:00:00 2001 From: Kris <2893855659@qq.com> Date: Thu, 9 Jul 2026 04:20:26 +0800 Subject: [PATCH] =?UTF-8?q?feat(wecom):=20=E5=AE=8C=E5=96=84=E5=AE=A2?= =?UTF-8?q?=E6=9C=8D=E6=B6=88=E6=81=AF=E9=80=82=E9=85=8D=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. 新增客服open_kfid缓存常量与key生成工具 2. 重构入站适配器缓存客服映射关系 3. 重写出站适配器使用缓存判断客服场景 4. 修复多选卡片构建逻辑与日志方法调用 5. 调整wechat_woc常量导入顺序 6. 优化命令适配器配置异常处理逻辑 --- .../yuxi/channels/plugins/wechat_woc/entry.py | 2 +- .../yuxi/channels/plugins/wecom/_constants.py | 5 ++ .../plugins/wecom/adapters/command_adapter.py | 13 +++-- .../plugins/wecom/adapters/inbound_adapter.py | 20 +++++++- .../wecom/adapters/outbound_adapter.py | 48 ++++++++++++------- .../wecom/adapters/streaming_adapter.py | 23 ++++++++- .../wecom/adapters/whitelist_adapter.py | 4 +- .../channels/plugins/wecom/card_builder.py | 16 +++++-- .../yuxi/channels/plugins/wecom/entry.py | 2 +- 9 files changed, 105 insertions(+), 28 deletions(-) diff --git a/backend/package/yuxi/channels/plugins/wechat_woc/entry.py b/backend/package/yuxi/channels/plugins/wechat_woc/entry.py index a1cf3672..2f35d2d4 100644 --- a/backend/package/yuxi/channels/plugins/wechat_woc/entry.py +++ b/backend/package/yuxi/channels/plugins/wechat_woc/entry.py @@ -63,6 +63,7 @@ from yuxi.channels.contract.plugin.manifest import ( PluginManifest, ) +from ._constants import DEFAULT_MAX_CONNECTIONS from .adapters.capability_prover import WocCapabilityProver from .adapters.directory_adapter import WeChatWocDirectoryAdapter from .adapters.doctor_adapter import WeChatWocDoctorAdapter @@ -77,7 +78,6 @@ from .adapters.status_adapter import WeChatWocStatusAdapter from .adapters.stream_connector_adapter import WeChatWocStreamConnectorAdapter from .adapters.whitelist_adapter import WeChatWocWhitelistAdapter from .adapters.wizard_adapter import WeChatWocWizardAdapter -from ._constants import DEFAULT_MAX_CONNECTIONS from .lifecycle import WeChatWocLifecycleHandler from .woc_bridge_client import WocBridgeClient diff --git a/backend/package/yuxi/channels/plugins/wecom/_constants.py b/backend/package/yuxi/channels/plugins/wecom/_constants.py index 4763d053..b9767565 100644 --- a/backend/package/yuxi/channels/plugins/wecom/_constants.py +++ b/backend/package/yuxi/channels/plugins/wecom/_constants.py @@ -66,6 +66,10 @@ RESPONSE_CODE_CACHE_TTL_SEC = 72 * 3600 # 消息反向映射缓存 TTL(秒),1 天 MSG_CACHE_TTL_SEC = 86400 +# 客服 open_kfid 映射缓存 TTL(秒),1 天(对齐 MSG_CACHE_TTL_SEC, +# 覆盖客服会话生命周期,确保出站回复能反查到 open_kfid) +KF_OPEN_KFID_CACHE_TTL_SEC = 86400 + # --------------------------------------------------------------------------- # 企业微信 API 常量 # --------------------------------------------------------------------------- @@ -94,6 +98,7 @@ __all__ = [ "USER_CACHE_TTL_SEC", "RESPONSE_CODE_CACHE_TTL_SEC", "MSG_CACHE_TTL_SEC", + "KF_OPEN_KFID_CACHE_TTL_SEC", "WEBHOOK_TIMEOUT_SEC", "DUPLICATE_CHECK_INTERVAL_SEC", "RESPONSE_CODE_VALID_HOURS", diff --git a/backend/package/yuxi/channels/plugins/wecom/adapters/command_adapter.py b/backend/package/yuxi/channels/plugins/wecom/adapters/command_adapter.py index ed6a3490..f01ba82d 100644 --- a/backend/package/yuxi/channels/plugins/wecom/adapters/command_adapter.py +++ b/backend/package/yuxi/channels/plugins/wecom/adapters/command_adapter.py @@ -27,6 +27,7 @@ from yuxi.channels.contract.dtos.config import ConfigScope from yuxi.channels.contract.errors import ( DependencyError, Error, + NotFoundError, ValidationError, ) from yuxi.channels.contract.ports.driven.config_port import ConfigPort @@ -69,10 +70,11 @@ class WecomCommandAdapter: """获取渠道命令列表。 从 ``ConfigPort`` 读取 CHANNEL 作用域 ``commands`` 配置,返回 - ``ChannelCommand`` 元组。配置缺失或为空时返回内置默认命令 - (``/help`` / ``/status``)。 + ``ChannelCommand`` 元组。配置缺失(``NotFoundError``)或为空时 + 降级返回内置默认命令(``/help`` / ``/status``),确保命令功能 + 在配置异常时仍可用。 - @failure: 配置加载失败抛 ``DependencyError``。 + @failure: 配置系统故障(非 NotFoundError)抛 ``DependencyError``。 """ try: cv = await self._config.get( @@ -80,6 +82,11 @@ class WecomCommandAdapter: scope=ConfigScope.CHANNEL, target=CHANNEL_TARGET, ) + except NotFoundError: + # 配置未设置,降级返回内置默认命令 + return _DEFAULT_COMMANDS + except Error: + raise except Exception as exc: raise DependencyError(dep="wecom_config", cause=exc) from exc diff --git a/backend/package/yuxi/channels/plugins/wecom/adapters/inbound_adapter.py b/backend/package/yuxi/channels/plugins/wecom/adapters/inbound_adapter.py index cacf52ab..2a1acbf4 100644 --- a/backend/package/yuxi/channels/plugins/wecom/adapters/inbound_adapter.py +++ b/backend/package/yuxi/channels/plugins/wecom/adapters/inbound_adapter.py @@ -30,11 +30,12 @@ from yuxi.channels.contract.dtos.common import ( from yuxi.channels.contract.dtos.config import ConfigScope from yuxi.channels.contract.dtos.inbound import SignatureVerifyResult from yuxi.channels.contract.errors import DependencyError, Error +from yuxi.channels.contract.ports.driven.cache_port import CachePort from yuxi.channels.contract.ports.driven.config_port import ConfigPort from yuxi.channels.contract.ports.driven.logger_port import LoggerPort from .. import signature -from .._constants import WECOM_API_DEP +from .._constants import KF_OPEN_KFID_CACHE_TTL_SEC, WECOM_API_DEP from ..wecom_client import WecomClient # 企业微信 MsgType → MessageFormat 映射 @@ -46,6 +47,11 @@ _RESOURCE_SCHEME = "wecom" _RESOURCE_HOST = "resource" +def _kf_open_kfid_key(account_id: str, peer_id: str) -> str: + """构造 ``external_userid → open_kfid`` 反向映射的缓存 key。""" + return f"wecom:kf:{account_id}:{peer_id}" + + class WecomInboundAdapter: """企业微信入站适配器。 @@ -64,10 +70,12 @@ class WecomInboundAdapter: client: WecomClient, config_port: ConfigPort, logger_port: LoggerPort, + cache_port: CachePort, ) -> None: self._client = client self._config = config_port self._logger = logger_port + self._cache = cache_port async def normalizeInbound(self, raw_event: RawEvent) -> MessageContent: """将企业微信原始事件规范化为统一消息内容。 @@ -99,6 +107,16 @@ class WecomInboundAdapter: account_id = raw_event.account_id or "" text, fmt, attachments = _parse_message_content(event, account_id) + # 客服场景:写入 external_userid → open_kfid 映射,供出站适配器反查 + # open_kfid 以切换客服 API 域并填充消息体(B-4 修复) + open_kfid = event.get("OpenKfId", "") or event.get("open_kfid", "") + if open_kfid and from_user: + await self._cache.set( + _kf_open_kfid_key(account_id, from_user), + open_kfid, + ttl_seconds=KF_OPEN_KFID_CACHE_TTL_SEC, + ) + # event_id 优先使用 MsgId,缺失时回退到 CreateTime + FromUserName event_id = msg_id or f"{create_time}_{from_user}" diff --git a/backend/package/yuxi/channels/plugins/wecom/adapters/outbound_adapter.py b/backend/package/yuxi/channels/plugins/wecom/adapters/outbound_adapter.py index 920d1370..9cf68bc7 100644 --- a/backend/package/yuxi/channels/plugins/wecom/adapters/outbound_adapter.py +++ b/backend/package/yuxi/channels/plugins/wecom/adapters/outbound_adapter.py @@ -57,6 +57,11 @@ def _response_code_key(channel_msg_id: str) -> str: return f"wecom:response_code:{channel_msg_id}" +def _kf_open_kfid_key(account_id: str, peer_id: str) -> str: + """构造 ``external_userid → open_kfid`` 反向映射的缓存 key。""" + return f"wecom:kf:{account_id}:{peer_id}" + + # 附件类型 → 企业微信媒体类型映射 _ATTACH_MEDIA_TYPE = { "image": "image", @@ -97,8 +102,9 @@ class WecomOutboundAdapter: ) -> str: """发送消息,返回企业微信 ``msgid``。 - 依据设计 §2.1.2 / 决策 8,当 ``peer_id`` 为客服场景(``open_kfid`` - 前缀)时切换至 ``kf_secret`` 并调用 ``/cgi-bin/kf/send_msg``;其余 + 依据设计 §2.1.2 / 决策 8,当 ``peer_id`` 存在客服场景映射 + (``external_userid → open_kfid``)时切换至 ``kf_secret`` 并调用 + ``/cgi-bin/kf/send_msg``,消息体补充 ``open_kfid`` 字段;其余 场景调用应用消息 ``/cgi-bin/message/send``。成功后缓存 ``msgid`` → ``account_id`` 反向映射。若响应含 ``response_code``(模板卡片 消息),同时缓存供 ``updateMessage`` 使用。 @@ -109,8 +115,10 @@ class WecomOutboundAdapter: msg_body = _build_msg_body(payload) msg_body["touser"] = peer_id + open_kfid = await self._resolve_open_kfid(account_id, peer_id) try: - if _is_kf_peer(peer_id): + if open_kfid: + msg_body["open_kfid"] = open_kfid resp = await self._client.send_kf_message(account_id, msg_body) else: resp = await self._client.send_message(account_id, msg_body) @@ -185,7 +193,7 @@ class WecomOutboundAdapter: part_type, msg_body = first_part msg_body["touser"] = peer_id try: - msg_id = await self._send_msg(account_id, msg_body, sent_ids) + msg_id = await self._send_msg(account_id, msg_body, sent_ids, peer_id=peer_id) except Error: raise except Exception as exc: @@ -352,6 +360,18 @@ class WecomOutboundAdapter: raise NotFoundError(resource="response_code", id=channel_msg_id) return opt.unwrap() + async def _resolve_open_kfid(self, account_id: str, peer_id: str) -> str: + """从缓存反查 ``peer_id`` 对应的 ``open_kfid``。 + + 客服场景下入站适配器写入 ``external_userid → open_kfid`` 映射, + 出站时据此判断是否走客服 API 并填充消息体 ``open_kfid`` 字段。 + 无映射时返回空字符串(非客服场景)。 + """ + opt = await self._cache.get(_kf_open_kfid_key(account_id, peer_id)) + if opt.is_nothing(): + return "" + return opt.unwrap() + async def _send_msg( self, account_id: str, @@ -362,10 +382,15 @@ class WecomOutboundAdapter: ) -> str: """发送单条消息并记录到 ``sent_ids``(供回滚使用)。 - 当 ``peer_id`` 为客服场景时切换至 ``kf_secret`` API 域。 + 当 ``peer_id`` 存在客服场景映射时切换至 ``kf_secret`` API 域, + 并在消息体中补充 ``open_kfid`` 字段。 """ + open_kfid = "" + if peer_id: + open_kfid = await self._resolve_open_kfid(account_id, peer_id) try: - if peer_id and _is_kf_peer(peer_id): + if open_kfid: + msg_body["open_kfid"] = open_kfid resp = await self._client.send_kf_message(account_id, msg_body) else: resp = await self._client.send_message(account_id, msg_body) @@ -430,7 +455,7 @@ class WecomOutboundAdapter: msg_body = _build_attachment_msg_body(attachment.type, media_id) msg_body["touser"] = peer_id - return await self._send_msg(account_id, msg_body, sent_ids) + return await self._send_msg(account_id, msg_body, sent_ids, peer_id=peer_id) async def _rollback(self, account_id: str, sent_ids: list[str]) -> None: """撤回已发送消息(best-effort,失败记录日志不抛异常)。""" @@ -523,15 +548,6 @@ def _build_card_data(payload: FormattedMessage) -> dict[str, Any]: return dict(card["template_card"]) -def _is_kf_peer(peer_id: str) -> bool: - """判断 peer_id 是否为客服场景(open_kfid 前缀)。 - - 企业微信客服账号 ID 以 ``kfid_`` / ``kf_`` 前缀开头(见企业微信开发 - 文档 "客服账号 ID" 说明),据此切换 ``kf_secret`` API 域。 - """ - return peer_id.startswith(("kfid_", "kf_")) - - def _build_attachment_msg_body( attach_type: str, media_id: str, diff --git a/backend/package/yuxi/channels/plugins/wecom/adapters/streaming_adapter.py b/backend/package/yuxi/channels/plugins/wecom/adapters/streaming_adapter.py index 3ab865ba..acee9cd0 100644 --- a/backend/package/yuxi/channels/plugins/wecom/adapters/streaming_adapter.py +++ b/backend/package/yuxi/channels/plugins/wecom/adapters/streaming_adapter.py @@ -60,6 +60,11 @@ def _card_key(card_id: str) -> str: return f"wecom:card:{card_id}" +def _kf_open_kfid_key(account_id: str, peer_id: str) -> str: + """构造 ``external_userid → open_kfid`` 反向映射的缓存 key。""" + return f"wecom:kf:{account_id}:{peer_id}" + + class WecomStreamingAdapter: """企业微信流式适配器。 @@ -102,7 +107,12 @@ class WecomStreamingAdapter: msg_body: dict[str, Any] = {"touser": peer_id} msg_body.update(card) try: - resp = await self._client.send_message(account_id, msg_body) + open_kfid = await self._resolve_open_kfid(account_id, peer_id) + if open_kfid: + msg_body["open_kfid"] = open_kfid + resp = await self._client.send_kf_message(account_id, msg_body) + else: + resp = await self._client.send_message(account_id, msg_body) except Error: # 契约异常(DependencyError/TimeoutError 等)向上透传,由调用方 # 据异常类型决定重试/降级/失败语义,对齐 Protocol @failure 契约。 @@ -269,5 +279,16 @@ class WecomStreamingAdapter: raise NotFoundError(resource="streaming_card", id=card_id) return opt.unwrap() + async def _resolve_open_kfid(self, account_id: str, peer_id: str) -> str: + """从缓存反查 ``peer_id`` 对应的 ``open_kfid``。 + + 客服场景下入站适配器写入 ``external_userid → open_kfid`` 映射, + 流式出站时据此判断是否走客服 API。无映射时返回空字符串。 + """ + opt = await self._cache.get(_kf_open_kfid_key(account_id, peer_id)) + if opt.is_nothing(): + return "" + return opt.unwrap() + __all__ = ["WecomStreamingAdapter"] diff --git a/backend/package/yuxi/channels/plugins/wecom/adapters/whitelist_adapter.py b/backend/package/yuxi/channels/plugins/wecom/adapters/whitelist_adapter.py index 16744798..796991fc 100644 --- a/backend/package/yuxi/channels/plugins/wecom/adapters/whitelist_adapter.py +++ b/backend/package/yuxi/channels/plugins/wecom/adapters/whitelist_adapter.py @@ -286,7 +286,7 @@ class WecomWhitelistAdapter: entries: list[WhitelistEntry] = [] for raw in raw_list: if not isinstance(raw, dict): - await self._logger.warning( + await self._logger.warn( "wecom whitelist entry skipped: not a dict", account_id=account_id, policy_type=policy_type.value, @@ -297,7 +297,7 @@ class WecomWhitelistAdapter: entries.append(whitelistEntryFromDict(raw)) except ValidationError as exc: # 跳过历史脏数据,避免单条非法条目阻断整批读取 - await self._logger.warning( + await self._logger.warn( "wecom whitelist entry skipped: invalid entry", account_id=account_id, policy_type=policy_type.value, diff --git a/backend/package/yuxi/channels/plugins/wecom/card_builder.py b/backend/package/yuxi/channels/plugins/wecom/card_builder.py index 2e717c06..32a0849d 100644 --- a/backend/package/yuxi/channels/plugins/wecom/card_builder.py +++ b/backend/package/yuxi/channels/plugins/wecom/card_builder.py @@ -171,9 +171,19 @@ def _build_vote_options(selects: tuple[Any, ...]) -> list[dict[str, Any]]: def _build_multiple_options(checkboxes: tuple[Any, ...]) -> list[dict[str, Any]]: - """构建多项选择型 checkboxes。""" + """构建多项选择型 checkboxes(嵌套结构)。 + + 企业微信 multiple_interaction 卡片要求每个元素含 ``question_name`` 与 + ``option_list``(嵌套数组),而非扁平列表。每个 ``RichMessageCheckbox`` + 对应一个问题组,``cb.label`` 为问题名,``cb.options`` 为选项列表。 + """ result: list[dict[str, Any]] = [] for cb in checkboxes: - for opt in cb.options: - result.append({"text": opt.label, "key": opt.value}) + option_list = [{"text": opt.label, "key": opt.value} for opt in cb.options] + result.append( + { + "question_name": cb.label, + "option_list": option_list, + } + ) return result diff --git a/backend/package/yuxi/channels/plugins/wecom/entry.py b/backend/package/yuxi/channels/plugins/wecom/entry.py index e0ab7700..de9069e1 100644 --- a/backend/package/yuxi/channels/plugins/wecom/entry.py +++ b/backend/package/yuxi/channels/plugins/wecom/entry.py @@ -133,7 +133,7 @@ def channel_entry(host: PluginHost, manifest: ChannelManifest) -> PluginManifest # 15 个列表追加 + 1 个 identity_resolver 单实例赋值 host.registerAdapter( "inbound", - WecomInboundAdapter(client, config_port, logger_port), + WecomInboundAdapter(client, config_port, logger_port, cache_port), ) host.registerAdapter( "outbound",