ForcePilot/backend/package/yuxi/channels/plugins/wechat_woc/lifecycle.py
Kris cfc42f5f06 feat: 完成渠道传输与权限体系多维度迭代
此提交包含多项核心功能迭代与问题修复:
1.  新增多操作权限配置,完善权限控制覆盖范围
2.  重构出站流水线适配器参数,优化组件依赖关系
3.  新增渠道账号配置变更事件,支持动态重建传输 worker
4.  实现全链路 trace_id 透传,优化调试追踪能力
5.  新增传输任务最大重启次数配置,避免无限崩溃循环
6.  优化幂等冲突错误体系,重构错误继承与提示信息
7.  增强 bridge_url 安全校验,新增 SSRF 防护与 HTTPS 强制校验
8.  完善 SSE 断线补全逻辑,新增分页与消息数限制
9.  重构 WeChatWoc 插件生命周期与资源管理,优化连接池与缓存清理
10. 修复多处代码逻辑bug,提升系统稳定性与可维护性
2026-07-09 20:26:49 +08:00

384 lines
16 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`` scheme生产环境强制 ``https://``
开发环境通过 ``allow_insecure_localhost`` 豁免 localhost非法时抛
``ValidationError``;空值/类型错误抛 ``ConfigRestartRequiredError``
触发宿主重启回滚FR-37 / P0-4
- ``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
from ._url_validators import _validate_bridge_url_host, _validate_bridge_url_scheme
# 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 在途请求后关闭 SSE 连接、httpx 连接池并清空缓存。
超时 30s由宿主控制必须释放所有资源不得留下孤儿资源
FR-32 资源释放约束)。
关停顺序§15.2P1-6 增强):
1. ``set_active(False)`` + ``drain(30)`` 等待在途请求完成
2. ``close_all_sse()`` 关闭所有活跃 SSE 长连接(独立 httpx client
不复用共享连接池,必须先于共享连接池关闭,避免孤儿连接)
3. ``detach_http_client`` 解除 client 引用(避免悬挂)
4. ``aclose()`` 关闭共享 httpx 连接池(异常告警不抛)
5. ``cache_port.invalidate("wechat_woc:*")`` 清空插件运行时缓存
(异常告警不抛,遵循 CachePort 写故障降级约定)
@post
- ``_http_client=None``、``_started=False``
- CachePort 中 ``wechat_woc:*`` 模式缓存已失效
- 所有活跃 SSE handle 已关闭
@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,
)
# 2. 关闭所有活跃 SSE handle独立 httpx client先于共享连接池关闭
# 避免孤儿连接。StreamWorker 可能已停止但 handle 未显式关闭,此处兜底)
await self._client.close_all_sse()
# 3. 解除共享连接池引用(避免悬挂)
self._client.detach_http_client()
# 4. 关闭 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
# 5. 清空 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 scheme 与 host非法抛 ValidationError。
``bridge_url`` 为 wechat_woc 唯一不可降级校验的配置项:生产环境
必须使用 ``https://``;开发环境可通过 ``allow_insecure_localhost=true``
豁免 ``http://localhost`` / ``127.0.0.1`` / ``::1``P0-4。scheme
校验通过后追加 host 校验拦截内网 IP 段SSRF 防护P1-8
空值或类型错误抛 ``ConfigRestartRequiredError`` 触发宿主重启回滚
FR-37
校验通过后仅记录日志不缓存基线wechat_woc 的 bridge_url 实时
从 ConfigPort 读取,无需本地缓存)。``old_config`` 由宿主通过关键字
参数传入wechat_woc 仅做格式校验、无差异化校验需求,故不消费。
@pre
- ``new_config`` 为新配置字典,含 ``bridge_url`` 字段
@post
- 校验通过日志记录配置即时生效bridge_url 实时读取)
- 校验失败:抛 ``ValidationError`` / ``ConfigRestartRequiredError``,宿主回滚
@failure
- ``ConfigRestartRequiredError````bridge_url`` 为空或类型错误
- ``ValidationError````bridge_url`` scheme 非法或 host 命中内网段
@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
allow_insecure = bool(new_config.get("allow_insecure_localhost", False))
_validate_bridge_url_scheme(bridge_url, allow_insecure)
_validate_bridge_url_host(bridge_url, allow_insecure)
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"]