ForcePilot/backend/package/yuxi/channels/plugins/wechat_mp/lifecycle.py

358 lines
14 KiB
Python
Raw Normal View History

"""微信公众号渠道插件生命周期钩子处理。
实现 ``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=CHANNEL_TARGET,
)
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=CHANNEL_TARGET,
)
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"]