ForcePilot/backend/package/yuxi/channels/plugins/wechat_ilink/lifecycle.py
Kris 2a2385bacc refactor(wechat-ilink): 重构微信iLink插件代码,统一常量与缓存键格式
本次重构对微信iLink渠道插件进行了全面整理:
1.  新增共享常量模块`_constants.py`,集中管理所有跨模块共享配置与标识,对齐manifest单真相源
2.  统一缓存键格式为`wechat_ilink:{account_id}:{key}`,修复白名单存储键顺序错误
3.  重构登录适配器,移除不必要的account_id参数,简化接口调用
4.  重构生命周期相关代码,新增缓存清理逻辑,支持按账户清理孤儿缓存
5.  移除冗余重复定义的常量与配置默认值,统一从常量模块导入
6.  更新manifest配置,补充传输模式与能力要求声明
7.  优化出站适配器,新增续传支持与分片发送逻辑
2026-07-08 22:56:45 +08:00

219 lines
9.0 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""微信 iLinkClawBot渠道插件生命周期钩子处理。
实现 ``LifecycleHookHandler`` Protocol管理插件级资源httpx 连接池),
响应宿主生命周期状态变迁。
设计要点:
- LifecycleHandler 为单实例(非 per-account可持有 mutable 状态用于
资源管理(与适配器的 INV-5 无 mutable 状态约束不同)。
- httpx 连接池在 ``onInit`` 创建并注入 ``ILinkClient````onUnload`` 解除
引用后关闭;连接数上限对齐 manifest.json 中
``resource_quota.max_connections=10``。
- 长轮询拉取由 ``StreamWorker`` 通过 ``PullerAdapter`` 统一管理,
``LifecycleHandler`` 不持有长轮询句柄。
- ``onReconfigure`` 校验不可热更新字段,变更时抛 ``ConfigRestartRequiredError``
校验失败保持原基线以支持宿主回滚。
- ``onFail`` 不抛异常、不静默吞错logger 失败时回退到 ``sys.stderr.write``
不阻断 FR-36 优雅降级流程。
依赖方向:仅 import ``yuxi.channels.contract.*`` + 标准库 + httpx
不污染框架层。
"""
from __future__ import annotations
import sys
from typing import TYPE_CHECKING, Any
import httpx
from yuxi.channels.contract.dtos.config import ConfigField
from yuxi.channels.contract.errors import ConfigRestartRequiredError
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
if TYPE_CHECKING:
from .ilink_client import ILinkClient
from ._constants import (
DEFAULT_MAX_CONNECTIONS,
DEFAULT_MAX_KEEPALIVE_CONNECTIONS,
DRAIN_TIMEOUT_SECONDS,
HTTP_TIMEOUT_SECONDS,
)
# CachePort 失效模式(清空插件所有运行时缓存,避免孤儿键残留)
_CACHE_INVALIDATE_PATTERN = "wechat_ilink:*"
class WeChatILinkLifecycleHandler:
"""微信 iLink 渠道插件生命周期钩子处理。
单实例,管理插件级资源生命周期。``_http_client`` 为共享连接池,通过
``attach_http_client`` 注入 ``ILinkClient`` 供其复用(避免每请求新建
TCP 连接);``onReconfigure`` 通过入参 ``old_config`` 做差异化校验,
不再自行维护基线缓存。长轮询连接由 ``StreamWorker`` 管理,本处理器不持有拉取句柄。
"""
def __init__(
self,
config_port: ConfigPort,
logger_port: LoggerPort,
cache_port: CachePort,
config_schema: tuple[ConfigField, ...],
client: ILinkClient | None = None,
max_connections: int = DEFAULT_MAX_CONNECTIONS,
) -> None:
self._config = config_port
self._logger = logger_port
self._cache = cache_port
self._client = client
self._max_connections = max_connections
self._http_client: httpx.AsyncClient | None = None
self._started: bool = False
# 从 config_schema 派生不可热更新键hot_reloadable=False 的字段 key
self._restart_required_keys: tuple[str, ...] = tuple(
field.key for field in config_schema if not field.hot_reloadable
)
# ------------------------------------------------------------------
# LifecycleHookHandler Protocol 实现
# ------------------------------------------------------------------
async def onInit(self) -> None:
"""初始化资源:创建 httpx 连接池并注入 ILinkClient。"""
self._http_client = httpx.AsyncClient(
timeout=HTTP_TIMEOUT_SECONDS,
limits=httpx.Limits(
max_connections=self._max_connections,
max_keepalive_connections=DEFAULT_MAX_KEEPALIVE_CONNECTIONS,
),
)
if self._client is not None:
self._client.attach_http_client(self._http_client)
await self._logger.info("WeChat iLink plugin initialized")
async def onStart(self) -> None:
"""标记插件就绪:允许 ILinkClient 接受新请求。"""
self._started = True
if self._client is not None:
self._client.set_active(True)
await self._logger.info("WeChat iLink plugin started")
async def onStop(self) -> None:
"""停止插件:拒绝新请求,等待在途请求完成(默认 30s 超时)。
先 ``set_active(False)`` 阻止新请求进入 ``_execute_http``,再
``drain(30)`` 等待在途请求完成。超时后记录告警但仍继续关闭流程。
"""
await self._logger.info(
"WeChat iLink plugin stopping, waiting for in-flight requests",
)
self._started = False
if self._client is not None:
self._client.set_active(False)
drained = await self._client.drain(timeout=DRAIN_TIMEOUT_SECONDS)
if not drained:
await self._logger.warn(
"WeChat iLink plugin drain timed out, in-flight requests may be interrupted",
timeout=DRAIN_TIMEOUT_SECONDS,
)
async def onPause(self) -> None:
"""暂停插件:停止接收新请求。"""
self._started = False
if self._client is not None:
self._client.set_active(False)
await self._logger.info("WeChat iLink plugin paused")
async def onResume(self) -> None:
"""恢复插件:重新接收请求。"""
self._started = True
if self._client is not None:
self._client.set_active(True)
await self._logger.info("WeChat iLink plugin resumed")
async def onUnload(self) -> None:
"""释放所有资源drain 在途请求后关闭 httpx 连接池并清空缓存。
长轮询连接由 ``StreamWorker`` 在宿主关停时首先停止,
本方法先 drain 在途请求(默认 30s 超时),再解除 client 引用并
关闭连接池,最后清空 CachePort 中 ``wechat_ilink:*`` 缓存避免
孤儿键残留FR-32 资源释放约束)。
"""
# 1. 先 drain 在途请求,避免 aclose 打断正在进行的请求
if self._client is not None:
self._client.set_active(False)
drained = await self._client.drain(timeout=DRAIN_TIMEOUT_SECONDS)
if not drained:
await self._logger.warn(
"WeChat iLink plugin unload drain timed out, in-flight requests may be interrupted",
timeout=DRAIN_TIMEOUT_SECONDS,
)
self._client.detach_http_client()
# 2. 关闭 httpx 连接池(异常告警不抛,避免阻断后续缓存清理)
if self._http_client is not None:
try:
await self._http_client.aclose()
except Exception as exc:
await self._logger.warn(
"WeChat iLink httpx client close failed during unload",
error=str(exc),
)
self._http_client = None
# 3. 清空 CachePort 中 wechat_ilink:* 缓存,避免孤儿键残留
try:
await self._cache.invalidate(pattern=_CACHE_INVALIDATE_PATTERN)
except Exception as exc:
await self._logger.warn(
"WeChat iLink cache invalidate failed during unload",
error=str(exc),
)
self._started = False
await self._logger.info("WeChat iLink plugin unloaded")
async def onReconfigure(
self,
*,
old_config: dict[str, Any] | None = None,
new_config: dict[str, Any],
) -> None:
"""配置热更新:校验不可热更新字段。
- ``_restart_required_keys``(从 config_schema 派生,``hot_reloadable=False``
的字段)变更抛 ``ConfigRestartRequiredError``FR-37
- ``old_config`` 为 ``None``(首次调用或无基线)时仅记录日志,不做对比。
- 校验失败由宿主回滚,本方法无副作用(不缓存基线,基线由宿主维护)。
"""
if old_config is None:
await self._logger.info(
"WeChat iLink plugin initial config baseline set",
)
return
for key in self._restart_required_keys:
new_value = new_config.get(key)
old_value = old_config.get(key)
if new_value != old_value:
raise ConfigRestartRequiredError(key=key) from None
await self._logger.info("WeChat iLink plugin reconfigured")
async def onFail(self, error: str) -> None:
"""失败清理不抛异常不阻断降级流程FR-36
logger 不可用时回退到 ``sys.stderr.write``,确保失败信息不丢失、
不被静默吞掉,同时不向上抛出异常以保证降级流程继续。
"""
self._started = False
try:
await self._logger.error("WeChat iLink plugin failed", error=error)
except Exception as exc:
sys.stderr.write(f"WeChat iLink plugin failed (logger unavailable): {error}\nlogger_error={exc!r}\n")
__all__ = ["WeChatILinkLifecycleHandler"]