实现了微信公众号全功能渠道插件,包含会话解析、消息收发、富消息适配、生命周期管理、主动探测等核心能力,遵循单真相源设计原则,所有配置与常量统一从manifest.json与_constants.py获取。
358 lines
14 KiB
Python
358 lines
14 KiB
Python
"""微信公众号渠道插件生命周期钩子处理。
|
||
|
||
实现 ``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"]
|