"""微信公众号渠道插件生命周期钩子处理。 实现 ``LifecycleHookHandler`` Protocol 的 8 个钩子(onInit / onStart / onStop / onPause / onResume / onUnload / onReconfigure / onFail), 管理插件级资源(httpx 连接池 + access_token 定时刷新任务)。 设计要点: - LifecycleHandler 为单实例,可持有 mutable 状态用于资源管理 (与适配器的 INV-5 无 mutable 状态约束不同)。 - httpx 连接池在 ``onInit`` 创建并注入 ``WeChatMpClient``,``onUnload`` 解除引用后关闭;连接数上限对齐 manifest.json 中 ``resource_quota.max_connections=20``。 - access_token 定时刷新:``onStart`` 启动 ``asyncio.Task``,按 ``token_refresh_interval_s``(默认 7000s)周期刷新;``onStop``/ ``onUnload`` 取消任务。 - ``onReconfigure`` 校验热更新配置项合法性。 - ``onUnload`` 在关闭连接池后清空 CachePort 中 ``wechat_mp:*`` 缓存, 避免插件卸载后残留孤儿缓存键(FR-32 资源释放约束)。 依赖方向:仅 import ``yuxi.channels.contract.*`` + 标准库 + httpx, 不污染框架层。 """ from __future__ import annotations import asyncio 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 from yuxi.channels.contract.ports.driven.persistence_port import PersistencePort if TYPE_CHECKING: from .wechat_mp_client import WeChatMpClient from ._constants import ( CHANNEL_TARGET, DEFAULT_MAX_CONNECTIONS, DEFAULT_TOKEN_REFRESH_INTERVAL_S, ENCODING_AES_KEY_LENGTH, ENCRYPT_MODES, ) # httpx 连接池 keepalive 连接数 _HTTP_MAX_KEEPALIVE_CONNECTIONS = 5 # 在途请求排空超时(秒) _DRAIN_TIMEOUT_SECONDS = 30.0 # CachePort 失效模式(清空插件所有运行时缓存,避免孤儿键) _CACHE_INVALIDATE_PATTERN = "wechat_mp:*" class WeChatMpLifecycleHandler: """微信公众号渠道插件生命周期钩子处理。 单实例,管理插件级资源生命周期。``_http_client`` 为共享连接池,通过 ``attach_http_client`` 注入 ``WeChatMpClient`` 供其复用; ``_token_refresh_task`` 为 access_token 定时刷新任务。 实现 8 个钩子(manifest.lifecycle 声明 init/start/stop/pause/resume/ unload/reconfigure/fail)。 """ def __init__( self, config_port: ConfigPort, logger_port: LoggerPort, cache_port: CachePort, config_schema: tuple[ConfigField, ...], client: WeChatMpClient | None = None, max_connections: int = DEFAULT_MAX_CONNECTIONS, persistence_port: PersistencePort | None = None, ) -> 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 self._config_schema: tuple[ConfigField, ...] = config_schema self._token_refresh_task: asyncio.Task[None] | None = None self._token_refresh_interval: int = DEFAULT_TOKEN_REFRESH_INTERVAL_S self._persistence: PersistencePort | None = persistence_port # ------------------------------------------------------------------ # LifecycleHookHandler Protocol 实现(8 个钩子) # ------------------------------------------------------------------ async def onInit(self) -> None: """初始化资源:创建 httpx 连接池并注入 WeChatMpClient。""" timeout_s = await self._read_http_timeout() self._http_client = httpx.AsyncClient( timeout=httpx.Timeout(timeout_s), limits=httpx.Limits( max_connections=self._max_connections, max_keepalive_connections=_HTTP_MAX_KEEPALIVE_CONNECTIONS, ), ) if self._client is not None: self._client.attach_http_client(self._http_client) # 读取 token 刷新间隔 self._token_refresh_interval = await self._read_token_refresh_interval() await self._logger.info("WeChat mp plugin initialized") async def onStart(self) -> None: """标记插件就绪:允许 WeChatMpClient 接受新请求,启动 token 刷新任务。""" self._started = True if self._client is not None: self._client.set_active(True) # 启动 access_token 定时刷新任务 self._start_token_refresh_task() await self._logger.info("WeChat mp plugin started") async def onStop(self) -> None: """停止插件:拒绝新请求,取消 token 刷新任务,等待在途请求完成。""" await self._logger.info( "WeChat mp plugin stopping, waiting for in-flight requests", ) self._started = False self._stop_token_refresh_task() 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 mp plugin drain timed out, in-flight requests may be interrupted", timeout=_DRAIN_TIMEOUT_SECONDS, ) async def onPause(self) -> None: """暂停插件:停止接收新请求,但在途请求继续完成。""" self._started = False self._stop_token_refresh_task() if self._client is not None: self._client.set_active(False) await self._logger.info("WeChat mp plugin paused") async def onResume(self) -> None: """恢复插件:重新接收新请求,重启 token 刷新任务。""" self._started = True if self._client is not None: self._client.set_active(True) self._start_token_refresh_task() await self._logger.info("WeChat mp plugin resumed") async def onUnload(self) -> None: """释放所有资源:取消 token 任务、drain 在途请求、关闭连接池、清空缓存。""" # 1. 取消 token 刷新任务 self._stop_token_refresh_task() # 2. drain 在途请求 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 mp plugin unload drain timed out", timeout=_DRAIN_TIMEOUT_SECONDS, ) self._client.detach_http_client() # 3. 关闭 httpx 连接池 if self._http_client is not None: try: await self._http_client.aclose() except Exception as exc: await self._logger.warn( "WeChat mp httpx client close failed during unload", error=str(exc), ) self._http_client = None # 4. 清空 CachePort 中 wechat_mp:* 缓存 try: await self._cache.invalidate(pattern=_CACHE_INVALIDATE_PATTERN) except Exception as exc: await self._logger.warn( "WeChat mp cache invalidate failed during unload", error=str(exc), ) self._started = False await self._logger.info("WeChat mp plugin unloaded") async def onReconfigure( self, *, old_config: dict[str, Any] | None = None, new_config: dict[str, Any], ) -> None: """配置热更新:校验热更新配置项合法性。 校验规则: - ``encrypt_mode`` 必须为 plaintext/compatible/safe 之一 - ``encoding_aes_key`` 在 compatible/safe 模式下必须为 43 位 - ``http_timeout_ms`` 必须在 5000~60000 区间 - ``token_refresh_interval_s`` 必须在 3600~7200 区间 """ encrypt_mode = new_config.get("encrypt_mode") if encrypt_mode is not None and encrypt_mode not in ENCRYPT_MODES: raise ConfigRestartRequiredError(key="encrypt_mode") from None encoding_aes_key = new_config.get("encoding_aes_key") if encoding_aes_key is not None: if not isinstance(encoding_aes_key, str) or len(encoding_aes_key) != ENCODING_AES_KEY_LENGTH: raise ConfigRestartRequiredError(key="encoding_aes_key") from None http_timeout_ms = new_config.get("http_timeout_ms") if http_timeout_ms is not None: try: ms = int(http_timeout_ms) if ms < 5000 or ms > 60000: raise ConfigRestartRequiredError(key="http_timeout_ms") from None except (TypeError, ValueError): raise ConfigRestartRequiredError(key="http_timeout_ms") from None token_refresh_interval_s = new_config.get("token_refresh_interval_s") if token_refresh_interval_s is not None: try: interval = int(token_refresh_interval_s) if interval < 3600 or interval > 7200: raise ConfigRestartRequiredError(key="token_refresh_interval_s") from None self._token_refresh_interval = interval except (TypeError, ValueError): raise ConfigRestartRequiredError(key="token_refresh_interval_s") from None await self._logger.info("WeChat mp plugin reconfigured") async def onFail(self, error: str) -> None: """失败清理:不抛异常,不阻断降级流程(FR-36)。""" self._started = False self._stop_token_refresh_task() try: await self._logger.error("WeChat mp plugin failed", error=error) except Exception as exc: sys.stderr.write(f"WeChat mp plugin failed (logger unavailable): {error}\nlogger_error={exc!r}\n") # ------------------------------------------------------------------ # access_token 定时刷新任务 # ------------------------------------------------------------------ def _start_token_refresh_task(self) -> None: """启动 access_token 定时刷新任务(若未运行)。""" if self._token_refresh_task is not None and not self._token_refresh_task.done(): return self._token_refresh_task = asyncio.create_task(self._token_refresh_loop()) def _stop_token_refresh_task(self) -> None: """停止 access_token 定时刷新任务。""" if self._token_refresh_task is not None: self._token_refresh_task.cancel() self._token_refresh_task = None async def _token_refresh_loop(self) -> None: """access_token 定时刷新循环。 按 ``token_refresh_interval_s`` 间隔周期刷新所有活跃账户的 access_token。 单次刷新失败仅记录告警,不中断循环。 """ await self._logger.info( "WeChat mp token refresh task started", interval_s=self._token_refresh_interval, ) while True: try: await asyncio.sleep(self._token_refresh_interval) await self._refresh_all_account_tokens() except asyncio.CancelledError: await self._logger.info("WeChat mp token refresh task cancelled") raise except Exception as exc: await self._logger.warn( "WeChat mp token refresh iteration failed", error=str(exc), ) async def _refresh_all_account_tokens(self) -> None: """刷新所有活跃账户的 access_token。 通过 PersistencePort 查询 ACTIVE 状态账户,逐个刷新 access_token。 单个账户刷新失败仅记录告警,不影响其他账户。 """ if self._client is None or self._persistence is None: return try: from yuxi.channels.contract.dtos.channel import ChannelType accounts = await self._persistence.listChannelAccounts( ChannelType(CHANNEL_TARGET), limit=100, ) except Exception as exc: await self._logger.warn( "WeChat mp token refresh: list accounts failed", error=str(exc), ) return for account in accounts: try: await self._client.refresh_access_token(account.account_id) except Exception as exc: await self._logger.warn( "WeChat mp token refresh: account refresh failed", account_id=account.account_id, error=str(exc), ) async def _read_token_refresh_interval(self) -> int: """从 ConfigPort 读取 token 刷新间隔。""" try: from yuxi.channels.contract.dtos.config import ConfigScope cv = await self._config.get( "token_refresh_interval_s", scope=ConfigScope.CHANNEL, target="wechat_mp", ) return int(cv.value) except Exception as exc: await self._logger.debug( "WeChat mp token refresh interval config read failed, using default", error=str(exc), ) return DEFAULT_TOKEN_REFRESH_INTERVAL_S async def _read_http_timeout(self) -> float: """从 ConfigPort 读取 HTTP 超时(秒),未配置时回退默认值。""" try: from yuxi.channels.contract.dtos.config import ConfigScope cv = await self._config.get( "http_timeout_ms", scope=ConfigScope.CHANNEL, target="wechat_mp", ) return int(cv.value) / 1000.0 except Exception as exc: await self._logger.debug( "WeChat mp http timeout config read failed, using default", error=str(exc), ) from ._constants import DEFAULT_HTTP_TIMEOUT_SECONDS return DEFAULT_HTTP_TIMEOUT_SECONDS __all__ = ["WeChatMpLifecycleHandler"]