此提交包含多项核心功能迭代与问题修复: 1. 新增多操作权限配置,完善权限控制覆盖范围 2. 重构出站流水线适配器参数,优化组件依赖关系 3. 新增渠道账号配置变更事件,支持动态重建传输 worker 4. 实现全链路 trace_id 透传,优化调试追踪能力 5. 新增传输任务最大重启次数配置,避免无限崩溃循环 6. 优化幂等冲突错误体系,重构错误继承与提示信息 7. 增强 bridge_url 安全校验,新增 SSRF 防护与 HTTPS 强制校验 8. 完善 SSE 断线补全逻辑,新增分页与消息数限制 9. 重构 WeChatWoc 插件生命周期与资源管理,优化连接池与缓存清理 10. 修复多处代码逻辑bug,提升系统稳定性与可维护性
384 lines
16 KiB
Python
384 lines
16 KiB
Python
"""微信 wechat_woc(WechatOnCloud)渠道插件生命周期钩子处理。
|
||
|
||
实现 ``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 易导致误超时,高于
|
||
# 120000ms(2 分钟)则偏离 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.2,P1-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"]
|