ForcePilot/backend/package/yuxi/channels/plugins/wechat_mp/lifecycle.py
Kris 7174500a9a feat(wechat-mp): 新增微信公众号渠道插件完整实现
实现了微信公众号全功能渠道插件,包含会话解析、消息收发、富消息适配、生命周期管理、主动探测等核心能力,遵循单真相源设计原则,所有配置与常量统一从manifest.json与_constants.py获取。
2026-07-08 22:57:21 +08:00

358 lines
14 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.

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