2026-07-08 22:57:21 +08:00
|
|
|
|
"""微信公众号渠道插件生命周期钩子处理。
|
|
|
|
|
|
|
|
|
|
|
|
实现 ``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,
|
2026-07-09 04:20:04 +08:00
|
|
|
|
target=CHANNEL_TARGET,
|
2026-07-08 22:57:21 +08:00
|
|
|
|
)
|
|
|
|
|
|
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,
|
2026-07-09 04:20:04 +08:00
|
|
|
|
target=CHANNEL_TARGET,
|
2026-07-08 22:57:21 +08:00
|
|
|
|
)
|
|
|
|
|
|
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"]
|