ForcePilot/backend/package/yuxi/channels/plugins/wechat_woc/lifecycle.py
Kris 1c6bed1403 feat: 完成微信公众号插件全链路修复与基础设施增强
本次提交包含多项核心改进:
1. 新增微信公众号插件拉取传输模式配置,完善manifest与manifest加载逻辑
2. 新增运行态状态机与幂等冲突错误体系,补充错误映射与领域错误导出
3. 优化出站与入站上下文,新增幂等键、流式中断标记等字段
4. 完善发件箱仓储与模型,新增失败条目查询、投递原子语义字段
5. 修复签名验证阶段异常捕获逻辑,防御性处理内置NotImplementedError
6. 新增出站预算释放方法,完善机器人循环预算管控
7. 优化出站管道格式阶段,新增消息长度校验逻辑
8. 完善出站打字指示器阶段,新增重复启动防御与状态同步
9. 重构出站标记失败阶段,按源状态分支处理状态转换
10. 新增入站幂等过滤阶段,修复入站路由阶段空指针问题
11. 优化出站恢复扫描器,修复状态机调用与聚合根重建逻辑
12. 完善微信公众号适配器,新增类型校验与异常包装
13. 修复数据库事务回滚逻辑,简化不必要的显式回滚操作
2026-07-07 18:54:33 +08:00

370 lines
15 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.

"""微信 wechat_wocWechatOnCloud渠道插件生命周期钩子处理。
实现 ``LifecycleHookHandler`` Protocol 的 8 个钩子onInit / onStart /
onStop / onPause / onResume / onUnload / onReconfigure / onFail
管理插件级资源httpx 连接池)并响应宿主生命周期状态变迁。
设计要点:
- LifecycleHandler 为单实例(非 per-account可持有 mutable 状态用于
资源管理(与适配器的 INV-5 无 mutable 状态约束不同)。
- httpx 连接池在 ``onInit`` 创建并注入 ``WocBridgeClient````onUnload``
解除引用后关闭;连接数上限对齐 manifest.json 中
``resource_quota.max_connections=10``。
- 短轮询拉取由 ``StreamWorker`` 通过 ``PullerAdapter`` 统一管理,
``LifecycleHandler`` 不持有轮询句柄。
- ``onReconfigure`` 校验 ``bridge_url`` 格式(必须以 ``http://`` 或
``https://`` 开头),非法时抛 ``ConfigRestartRequiredError`` 触发宿主
重启回滚FR-37
- ``onUnload`` 在关闭连接池后清空 CachePort 中 ``wechat_woc:*`` 缓存,
避免插件卸载后残留孤儿缓存键FR-32 资源释放约束)。
- ``onFail`` 不抛异常、不静默吞错logger 失败时回退到 ``sys.stderr.write``
不阻断 FR-36 优雅降级流程。
依赖方向:仅 import ``yuxi.channels.contract.*`` + 标准库 + httpx
不污染框架层。
"""
from __future__ import annotations
import os
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, ValidationError
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
if TYPE_CHECKING:
from .woc_bridge_client import WocBridgeClient
from ._constants import DEFAULT_MAX_CONNECTIONS, HTTP_TIMEOUT_SECONDS
# httpx 连接池默认配置;最终值由 discover 阶段解析的 manifest.resource_quota
# 注入,本默认值仅用于测试或未声明 resource_quota 的场景F-03 单真相源)。
_HTTP_MAX_KEEPALIVE_CONNECTIONS = 5
# 在途请求 drain 超时onStop/onUnload 等待在途请求完成的最长时间
_DRAIN_TIMEOUT_SECONDS = 30.0
# WOC_HTTP_TIMEOUT_MS 合法范围(毫秒):低于 100ms 易导致误超时,高于
# 120000ms2 分钟)则偏离 bridge HTTP 调用的合理上限,越界值抛 ValidationError
_HTTP_TIMEOUT_MS_MIN = 100
_HTTP_TIMEOUT_MS_MAX = 120_000
# CachePort 失效模式(清空插件所有运行时缓存,避免孤儿键)
_CACHE_INVALIDATE_PATTERN = "wechat_woc:*"
class WeChatWocLifecycleHandler:
"""微信 wechat_woc 渠道插件生命周期钩子处理。
单实例,管理插件级资源生命周期。``_http_client`` 为共享连接池,通过
``attach_http_client`` 注入 ``WocBridgeClient`` 供其复用(避免每请求
新建 TCP 连接);短轮询连接由 ``StreamWorker`` 管理,本处理器不持有
拉取句柄。
实现 8 个钩子manifest.lifecycle 声明 init/start/stop/pause/resume/
unload/reconfigure/fail。``onPause`` / ``onResume`` 通过
``set_active`` 切换 ``WocBridgeClient`` 请求接收开关,语义为"暂停
接收新请求"而非"停止拉取"——拉取 Worker 由宿主 ``StreamWorker``
在 PAUSED 状态下统一挂起,本钩子仅控制 HTTP 客户端可用性。
"""
def __init__(
self,
config_port: ConfigPort,
logger_port: LoggerPort,
cache_port: CachePort,
config_schema: tuple[ConfigField, ...],
client: WocBridgeClient | None = None,
max_connections: int = DEFAULT_MAX_CONNECTIONS,
) -> 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
# config_schema 保留用于未来扩展(如派生热更新键集合),当前 onReconfigure
# 仅校验 bridge_url 格式,不依赖 schema 派生键清单。
self._config_schema: tuple[ConfigField, ...] = config_schema
# ------------------------------------------------------------------
# LifecycleHookHandler Protocol 实现8 个钩子)
# ------------------------------------------------------------------
async def onInit(self) -> None:
"""初始化资源:创建 httpx 连接池并注入 WocBridgeClient。
超时 60s由宿主控制超时后标记插件失败。
@pre
- 宿主已注入 ConfigPort / LoggerPort / CachePort
- ``client``(若提供)尚未持有 httpx 连接池
@post
- ``_http_client`` 已创建并注入 ``WocBridgeClient``(若提供)
- 后续 HTTP 请求复用该连接池
@failure
- 无httpx.AsyncClient 构造失败由宿主捕获并触发 onFail
@consistency
- 状态机驱动:钩子完成后状态机推进至 INITIALIZED
"""
timeout_seconds = _read_http_timeout()
self._http_client = httpx.AsyncClient(
timeout=timeout_seconds,
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)
await self._logger.info("WeChat woc plugin initialized")
async def onStart(self) -> None:
"""标记插件就绪:允许 WocBridgeClient 接受新请求。
超时 60s由宿主控制
@post
- ``_started=True``
- ``WocBridgeClient.set_active(True)`` 已调用
@consistency
- 状态机驱动:钩子完成后状态机推进至 STARTED
"""
self._started = True
if self._client is not None:
self._client.set_active(True)
await self._logger.info("WeChat woc plugin started")
async def onStop(self) -> None:
"""停止插件:拒绝新请求,等待在途请求完成(默认 30s 超时)。
先 ``set_active(False)`` 阻止新请求进入 ``_execute_http``,再
``drain(30)`` 等待在途请求完成。超时后记录告警但仍继续关闭流程。
@post
- ``_started=False``
- ``WocBridgeClient.set_active(False)`` 已调用
- 在途请求已 drain超时则告警不抛异常
@consistency
- 状态机驱动:钩子完成后状态机推进至 STOPPED
- 幂等:停止已停止的插件安全
"""
await self._logger.info(
"WeChat woc plugin stopping, waiting for in-flight requests",
)
self._started = False
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 woc plugin drain timed out, in-flight requests may be interrupted",
timeout=_DRAIN_TIMEOUT_SECONDS,
)
async def onPause(self) -> None:
"""暂停插件:停止接收新请求,但在途请求继续完成。
与 ``onStop`` 的区别:不 drain 在途请求,仅 ``set_active(False)``
阻止新请求进入 ``_execute_http``,已接收的请求继续执行至完成。
适用于宿主临时挂起场景(如配置预校验、限流降级),恢复后调用
``onResume`` 即可重新接收请求。
@post
- ``_started=False``
- ``WocBridgeClient.set_active(False)`` 已调用
@consistency
- 状态机驱动:钩子完成后状态机推进至 PAUSED
- 幂等:暂停已暂停的插件安全
"""
self._started = False
if self._client is not None:
self._client.set_active(False)
await self._logger.info("WeChat woc plugin paused")
async def onResume(self) -> None:
"""恢复插件:重新接收新请求。
仅在 PAUSED 状态下由宿主调用,恢复 ``WocBridgeClient`` 请求接收
开关。若插件未启动(``_http_client`` 为 None仅标记 ``_started``
等待 ``onStart`` 完成资源装配。
@pre
- 插件处于 PAUSED 状态
@post
- ``_started=True``
- ``WocBridgeClient.set_active(True)`` 已调用(若 client 存在)
@consistency
- 状态机驱动:钩子完成后状态机推进至 STARTED
- 幂等:恢复已启动的插件安全
"""
self._started = True
if self._client is not None:
self._client.set_active(True)
await self._logger.info("WeChat woc plugin resumed")
async def onUnload(self) -> None:
"""释放所有资源drain 在途请求后关闭 httpx 连接池并清空缓存。
超时 30s由宿主控制必须释放所有资源不得留下孤儿资源
FR-32 资源释放约束)。
步骤:
1. ``set_active(False)`` + ``drain(30)`` 等待在途请求完成
2. ``detach_http_client`` 解除 client 引用(避免悬挂)
3. ``aclose()`` 关闭连接池(异常告警不抛)
4. ``cache_port.invalidate("wechat_woc:*")`` 清空插件运行时缓存
(异常告警不抛,遵循 CachePort 写故障降级约定)
@post
- ``_http_client=None``、``_started=False``
- CachePort 中 ``wechat_woc:*`` 模式缓存已失效
@consistency
- 状态机驱动:钩子完成后状态机推进至 UNLOADED
- 幂等:重复卸载仅清理已释放的资源
"""
# 1. 先 drain 在途请求,避免 aclose 打断正在进行的请求
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 woc plugin unload drain timed out, in-flight requests may be interrupted",
timeout=_DRAIN_TIMEOUT_SECONDS,
)
self._client.detach_http_client()
# 2. 关闭 httpx 连接池(异常告警不抛,避免阻断后续缓存清理)
if self._http_client is not None:
try:
await self._http_client.aclose()
except Exception as exc:
await self._logger.warn(
"WeChat woc httpx client close failed during unload",
error=str(exc),
)
self._http_client = None
# 3. 清空 CachePort 中 wechat_woc:* 缓存,避免孤儿键残留
try:
await self._cache.invalidate(pattern=_CACHE_INVALIDATE_PATTERN)
except Exception as exc:
await self._logger.warn(
"WeChat woc cache invalidate failed during unload",
error=str(exc),
)
self._started = False
await self._logger.info("WeChat woc plugin unloaded")
async def onReconfigure(
self,
*,
old_config: dict[str, Any] | None = None,
new_config: dict[str, Any],
) -> None:
"""配置热更新:校验 bridge_url 格式,非法抛 ConfigRestartRequiredError。
``bridge_url`` 为 wechat_woc 唯一不可降级校验的配置项:必须以
``http://`` 或 ``https://`` 开头,否则视为配置非法,抛
``ConfigRestartRequiredError`` 触发宿主重启回滚FR-37
校验通过后仅记录日志不缓存基线wechat_woc 的 bridge_url 实时
从 ConfigPort 读取,无需本地缓存)。``old_config`` 由宿主通过关键字
参数传入wechat_woc 仅做格式校验、无差异化校验需求,故不消费。
@pre
- ``new_config`` 为新配置字典,含 ``bridge_url`` 字段
@post
- 校验通过日志记录配置即时生效bridge_url 实时读取)
- 校验失败:抛 ``ConfigRestartRequiredError``,宿主回滚
@failure
- ``ConfigRestartRequiredError````bridge_url`` 格式非法
@consistency
- 状态机驱动:配置变更即时生效且支持回滚
- 幂等:相同配置重复应用产生等价结果
"""
bridge_url = new_config.get("bridge_url")
if not isinstance(bridge_url, str) or not bridge_url:
raise ConfigRestartRequiredError(key="bridge_url") from None
if not (bridge_url.startswith("http://") or bridge_url.startswith("https://")):
raise ConfigRestartRequiredError(key="bridge_url") from None
await self._logger.info("WeChat woc plugin reconfigured")
async def onFail(self, error: str) -> None:
"""失败清理不抛异常不阻断降级流程FR-36
插件初始化/启动/运行时失败时由宿主调用,标记 ``_started=False``
并记录错误日志。logger 不可用时回退到 ``sys.stderr.write``,确保
失败信息不丢失、不被静默吞掉,同时不向上抛出异常以保证降级流程
继续。
@post
- ``_started=False``
- 错误已记录logger 或 stderr 兜底)
- 不抛出任何异常
@consistency
- 状态机驱动:钩子完成后状态机推进至 FAILED 并触发降级
- 幂等:重复通知失败仅记录状态,无累积副作用
"""
self._started = False
try:
await self._logger.error("WeChat woc plugin failed", error=error)
except Exception as exc:
sys.stderr.write(f"WeChat woc plugin failed (logger unavailable): {error}\nlogger_error={exc!r}\n")
def _read_http_timeout() -> float:
"""读取 HTTP 客户端超时(秒)。
优先读取环境变量 ``WOC_HTTP_TIMEOUT_MS``(与 manifest.json env_vars 声明
对齐),解析为秒;未设置时回退到 ``HTTP_TIMEOUT_SECONDS``。
合法范围为 ``_HTTP_TIMEOUT_MS_MIN``~``_HTTP_TIMEOUT_MS_MAX``100ms~120s
越界值抛 ``ValidationError`` 暴露配置错误,不再静默替换为默认值。
Returns:
HTTP 超时秒数。
Raises:
ValidationError: ``WOC_HTTP_TIMEOUT_MS`` 值非整数或越界时抛出。
"""
env_value = os.environ.get("WOC_HTTP_TIMEOUT_MS")
if not env_value:
return HTTP_TIMEOUT_SECONDS
try:
ms = int(env_value)
except (ValueError, TypeError) as exc:
raise ValidationError(
field="WOC_HTTP_TIMEOUT_MS",
message=f"invalid_integer: {env_value!r}",
) from exc
if ms < _HTTP_TIMEOUT_MS_MIN or ms > _HTTP_TIMEOUT_MS_MAX:
raise ValidationError(
field="WOC_HTTP_TIMEOUT_MS",
message=(f"out_of_range: {ms}ms, expected [{_HTTP_TIMEOUT_MS_MIN}, {_HTTP_TIMEOUT_MS_MAX}]"),
)
return ms / 1000.0
__all__ = ["WeChatWocLifecycleHandler"]