refactor(wechat-woc,transport): 批量代码优化与功能增强

1.  修复代码格式与缩进问题,统一代码风格
2.  调整凭证缓存TTL为不过期,显式管理缓存失效
3.  重构wechat-woc出站请求,添加可重入锁保证同账号串行调用
4.  新增wechat-woc向导适配器配置端口支持,兼容开发环境localhost
5.  实现入站消息出站投递异步化,添加账号级并发限制
6.  新增transport启动前凭证缓存预热逻辑
7.  调整导入顺序与依赖位置,优化代码结构
This commit is contained in:
Kris 2026-07-11 05:42:26 +08:00
parent 15c1cd95ba
commit 977724d7c3
19 changed files with 356 additions and 115 deletions

View File

@ -26,6 +26,9 @@ from collections.abc import AsyncIterator
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from typing import TYPE_CHECKING, Any from typing import TYPE_CHECKING, Any
from sqlalchemy import select
from sqlalchemy.exc import SQLAlchemyError
from yuxi.channels.contract.dtos.agent_run import AgentRunCmd, AgentRunId from yuxi.channels.contract.dtos.agent_run import AgentRunCmd, AgentRunId
from yuxi.channels.contract.dtos.common import MessageContent from yuxi.channels.contract.dtos.common import MessageContent
from yuxi.channels.contract.dtos.option import Nothing, Option, Some from yuxi.channels.contract.dtos.option import Nothing, Option, Some
@ -44,8 +47,6 @@ from yuxi.channels.contract.ports.driven.logger_port import LoggerPort
from yuxi.channels.contract.ports.driven.service_account_port import ServiceAccountPort from yuxi.channels.contract.ports.driven.service_account_port import ServiceAccountPort
from yuxi.storage.postgres.manager import pg_manager from yuxi.storage.postgres.manager import pg_manager
from yuxi.storage.postgres.models_business import Conversation from yuxi.storage.postgres.models_business import Conversation
from sqlalchemy import select
from sqlalchemy.exc import SQLAlchemyError
if TYPE_CHECKING: if TYPE_CHECKING:
from yuxi.channels.contract.ports.driven.transaction_port import TransactionContext from yuxi.channels.contract.ports.driven.transaction_port import TransactionContext

View File

@ -1398,8 +1398,8 @@ class ConversationAdapter(ConversationPort):
row = result.one_or_none() row = result.one_or_none()
if row is None: if row is None:
return None return None
message = self._orm_to_message(row.MessageORM) message = self._orm_to_message(row.Message)
return self._attach_context(message, row.ConversationORM, row[2]) return self._attach_context(message, row.Conversation, row[2])
except IntegrityError as exc: except IntegrityError as exc:
raise self._translate_db_error(exc, "message") from exc raise self._translate_db_error(exc, "message") from exc
except SQLAlchemyError as exc: except SQLAlchemyError as exc:
@ -1504,8 +1504,8 @@ class ConversationAdapter(ConversationPort):
result = await self._db.execute(stmt) result = await self._db.execute(stmt)
messages: list[Message] = [] messages: list[Message] = []
for row in result.all(): for row in result.all():
message = self._orm_to_message(row.MessageORM) message = self._orm_to_message(row.Message)
self._attach_context(message, row.ConversationORM, row[2]) self._attach_context(message, row.Conversation, row[2])
messages.append(message) messages.append(message)
return tuple(messages) return tuple(messages)
except IntegrityError as exc: except IntegrityError as exc:
@ -1812,8 +1812,8 @@ class ConversationAdapter(ConversationPort):
result = await self._db.execute(stmt) result = await self._db.execute(stmt)
items: list[MessageSearchItem] = [] items: list[MessageSearchItem] = []
for row in result.all(): for row in result.all():
msg = row.MessageORM msg = row.Message
conv = row.ConversationORM conv = row.Conversation
sess = row[2] sess = row[2]
items.append( items.append(
MessageSearchItem( MessageSearchItem(

View File

@ -19,7 +19,6 @@ from datetime import UTC, datetime
from typing import Any from typing import Any
from uuid import uuid4 from uuid import uuid4
from yuxi.utils.datetime_utils import utc_now_naive
from yuxi.channels.application.context.control_plane_context import ( from yuxi.channels.application.context.control_plane_context import (
ControlPlaneContext, ControlPlaneContext,
) )
@ -54,6 +53,7 @@ from yuxi.channels.contract.ports.driven.content_review_repository_port import (
from yuxi.channels.contract.ports.driven.logger_port import LoggerPort from yuxi.channels.contract.ports.driven.logger_port import LoggerPort
from yuxi.channels.contract.ports.driven.persistence_port import PersistencePort from yuxi.channels.contract.ports.driven.persistence_port import PersistencePort
from yuxi.channels.contract.ports.driven.transaction_port import TransactionPort from yuxi.channels.contract.ports.driven.transaction_port import TransactionPort
from yuxi.utils.datetime_utils import utc_now_naive
# 内容审核单条内容长度上界 # 内容审核单条内容长度上界
MAX_CONTENT_LENGTH: int = 10000 MAX_CONTENT_LENGTH: int = 10000

View File

@ -196,7 +196,9 @@ class InboundIdempotencyStage:
existing_resource_id=str(record.record_id), existing_resource_id=str(record.record_id),
trace_id=context.trace_id, trace_id=context.trace_id,
) )
if record.status == "in_progress" and not _isStaleInProgress(record, _IDEMPOTENCY_TIMEOUT_SECONDS): if record.status == "in_progress" and not _isStaleInProgress(
record, _IDEMPOTENCY_TIMEOUT_SECONDS
):
# 锁内重查发现已被其他请求重建为新的非 stale # 锁内重查发现已被其他请求重建为新的非 stale
# in_progress 记录:抛冲突让渠道重试 # in_progress 记录:抛冲突让渠道重试
raise IdempotencyConflictError( raise IdempotencyConflictError(

View File

@ -98,11 +98,7 @@ class LoadBuildStage:
# condition 跳过)。阻塞消费事件流等待 AgentRun 完成后加载完整输出, # condition 跳过)。阻塞消费事件流等待 AgentRun 完成后加载完整输出,
# 由下游 outbox-persist/deliver 投递。流式模式下 stream_chunk_stage # 由下游 outbox-persist/deliver 投递。流式模式下 stream_chunk_stage
# 负责加载+投递,此处不介入。 # 负责加载+投递,此处不介入。
if ( if context.delivery_mode == "persistent" and context.agent_run_id and not context.stream_chunks:
context.delivery_mode == "persistent"
and context.agent_run_id
and not context.stream_chunks
):
await self._loadAgentRunOutput(context) await self._loadAgentRunOutput(context)
rich_message_fields = None rich_message_fields = None

View File

@ -41,12 +41,12 @@ from yuxi.channels.contract.dtos.channel import ChannelType
from yuxi.channels.contract.dtos.common import MessageContent, MessageFormat from yuxi.channels.contract.dtos.common import MessageContent, MessageFormat
from yuxi.channels.contract.dtos.config import ConfigScope from yuxi.channels.contract.dtos.config import ConfigScope
from yuxi.channels.contract.dtos.outbound import FormattedMessage from yuxi.channels.contract.dtos.outbound import FormattedMessage
from yuxi.channels.contract.dtos.stream_event import StreamEvent
from yuxi.channels.contract.dtos.streaming import ( from yuxi.channels.contract.dtos.streaming import (
ChunkResult, ChunkResult,
StreamChunk, StreamChunk,
StreamingCompleted, StreamingCompleted,
) )
from yuxi.channels.contract.dtos.stream_event import StreamEvent
from yuxi.channels.contract.errors.domain import ChannelDegradedError from yuxi.channels.contract.errors.domain import ChannelDegradedError
from yuxi.channels.contract.plugin.adapters.outbound_adapter import OutboundAdapter from yuxi.channels.contract.plugin.adapters.outbound_adapter import OutboundAdapter
from yuxi.channels.contract.plugin.adapters.streaming_adapter import StreamingAdapter from yuxi.channels.contract.plugin.adapters.streaming_adapter import StreamingAdapter
@ -331,9 +331,7 @@ class StreamChunkStage:
context.formatted_message = FormattedMessage( context.formatted_message = FormattedMessage(
content=content, content=content,
format=MessageFormat.TEXT, format=MessageFormat.TEXT,
attachments=context.formatted_message.attachments attachments=context.formatted_message.attachments if context.formatted_message is not None else (),
if context.formatted_message is not None
else (),
) )
return return
message_content = MessageContent(text=content, format=MessageFormat.TEXT) message_content = MessageContent(text=content, format=MessageFormat.TEXT)
@ -342,7 +340,5 @@ class StreamChunkStage:
content=formatted.content, content=formatted.content,
format=formatted.format, format=formatted.format,
rich_message=formatted.rich_message, rich_message=formatted.rich_message,
attachments=context.formatted_message.attachments attachments=context.formatted_message.attachments if context.formatted_message is not None else (),
if context.formatted_message is not None
else (),
) )

View File

@ -942,9 +942,7 @@ class BaseTransportWorker(ABC):
# 仅 StreamWorker 降级有意义PullerWorker permanent 失败时无 # 仅 StreamWorker 降级有意义PullerWorker permanent 失败时无
# 更低优先级模式可降级)。 # 更低优先级模式可降级)。
if self.transport_mode == "stream": if self.transport_mode == "stream":
await self._publishChannelDegraded( await self._publishChannelDegraded(channel_type, account_id, error.message, trace_id)
channel_type, account_id, error.message, trace_id
)
return True return True
if error.category == "rate_limited": if error.category == "rate_limited":

View File

@ -18,16 +18,20 @@ from yuxi.channels.application.transport.base_worker import (
from yuxi.channels.application.transport.puller_worker import PullerWorker from yuxi.channels.application.transport.puller_worker import PullerWorker
from yuxi.channels.application.transport.stream_worker import StreamWorker from yuxi.channels.application.transport.stream_worker import StreamWorker
from yuxi.channels.contract.dtos.channel import AccountFilter, AccountStatus, ChannelType from yuxi.channels.contract.dtos.channel import AccountFilter, AccountStatus, ChannelType
from yuxi.channels.contract.dtos.config import ConfigScope
from yuxi.channels.contract.dtos.health import TransportHealthSnapshot from yuxi.channels.contract.dtos.health import TransportHealthSnapshot
from yuxi.channels.contract.dtos.option import Some
from yuxi.channels.contract.dtos.plugin import DomainEvent, EventHandler from yuxi.channels.contract.dtos.plugin import DomainEvent, EventHandler
from yuxi.channels.contract.plugin.extension_point import EventSubscription from yuxi.channels.contract.plugin.extension_point import EventSubscription
from yuxi.channels.contract.plugin.manifest import FailurePolicy from yuxi.channels.contract.plugin.manifest import FailurePolicy
from yuxi.channels.contract.ports.driven import ( from yuxi.channels.contract.ports.driven import (
CachePort,
ConfigPort, ConfigPort,
LoggerPort, LoggerPort,
PersistencePort, PersistencePort,
) )
from yuxi.channels.core.registry import PluginRegistry from yuxi.channels.core.registry import PluginRegistry
from yuxi.utils.crypto import decrypt_sensitive_fields
__all__ = ["TransportManager"] __all__ = ["TransportManager"]
@ -61,6 +65,7 @@ class TransportManager:
logger: LoggerPort, logger: LoggerPort,
message_deliverer: Callable[[Any], Awaitable[Any]], message_deliverer: Callable[[Any], Awaitable[Any]],
transport_config: TransportConfig | None = None, transport_config: TransportConfig | None = None,
cache_port: CachePort | None = None,
) -> None: ) -> None:
"""初始化 TransportManager。 """初始化 TransportManager。
@ -73,6 +78,8 @@ class TransportManager:
logger: 日志端口 logger: 日志端口
message_deliverer: 入站消息投递回调 message_deliverer: 入站消息投递回调
transport_config: 传输配置 None 时使用默认配置 transport_config: 传输配置 None 时使用默认配置
cache_port: 缓存端口用于启动前预热凭证缓存 None
跳过预热向后兼容
""" """
self._plugin_registry = plugin_registry self._plugin_registry = plugin_registry
self._persistence_port = persistence_port self._persistence_port = persistence_port
@ -82,6 +89,7 @@ class TransportManager:
self._logger = logger self._logger = logger
self._message_deliverer = message_deliverer self._message_deliverer = message_deliverer
self._config = transport_config or TransportConfig() self._config = transport_config or TransportConfig()
self._cache_port = cache_port
self._puller_registry: dict[ChannelType, Any] = {} self._puller_registry: dict[ChannelType, Any] = {}
self._stream_connector_registry: dict[ChannelType, Any] = {} self._stream_connector_registry: dict[ChannelType, Any] = {}
@ -382,9 +390,7 @@ class TransportManager:
if account_key in self._degraded_accounts: if account_key in self._degraded_accounts:
self._degraded_accounts.discard(account_key) self._degraded_accounts.discard(account_key)
if self._puller_worker is not None: if self._puller_worker is not None:
await self._puller_worker.stop_account( await self._puller_worker.stop_account(channel_type, account_id, reason="recovered")
channel_type, account_id, reason="recovered"
)
await self._logger.info( await self._logger.info(
"channel transport recovered from degraded mode", "channel transport recovered from degraded mode",
trace_id=trace_id, trace_id=trace_id,
@ -420,9 +426,7 @@ class TransportManager:
强制以 pull 模式启动 PullerWorker 使用 强制以 pull 模式启动 PullerWorker 使用
""" """
transport_mode = ( transport_mode = (
force_mode force_mode if force_mode is not None else await self._resolveTransportMode(channel_type, account_id)
if force_mode is not None
else await self._resolveTransportMode(channel_type, account_id)
) )
puller_adapter = self._puller_registry.get(channel_type) puller_adapter = self._puller_registry.get(channel_type)
stream_adapter = self._stream_connector_registry.get(channel_type) stream_adapter = self._stream_connector_registry.get(channel_type)
@ -449,6 +453,11 @@ class TransportManager:
# 等原因未真正启动 task状态轻微不一致可接受下次状态变化时纠正 # 等原因未真正启动 task状态轻微不一致可接受下次状态变化时纠正
await self._touchPluginStatus(channel_type, account_id, "running", trace_id) await self._touchPluginStatus(channel_type, account_id, "running", trace_id)
# 启动前预热凭证缓存CachePort 命中则跳过,未命中从 ConfigPort 解密回填。
# 覆盖重启恢复与事件驱动路径,确保 worker 首次 poll 时凭证已就绪。
if self._cache_port is not None:
await self._preheatCredentials(account_id, trace_id)
if transport_mode == "pull": if transport_mode == "pull":
if puller_adapter is not None and self._puller_worker is not None: if puller_adapter is not None and self._puller_worker is not None:
await self._puller_worker.start_account(channel_type, account_id, puller_adapter) await self._puller_worker.start_account(channel_type, account_id, puller_adapter)
@ -464,6 +473,55 @@ class TransportManager:
elif puller_adapter is not None and self._puller_worker is not None: elif puller_adapter is not None and self._puller_worker is not None:
await self._puller_worker.start_account(channel_type, account_id, puller_adapter) await self._puller_worker.start_account(channel_type, account_id, puller_adapter)
async def _preheatCredentials(self, account_id: str, trace_id: str) -> None:
"""启动前预热凭证缓存。
CachePort 命中则跳过未命中从 ConfigPort 读取加密凭证解密后
回填 CachePort TTL覆盖系统重启Redis 缓存丢失等场景
确保插件客户端 ILinkClient首次 poll 时凭证已就绪
预热失败不阻断 transport 启动模式 A 渠道feishu/wecom
使用 ``credentials:{account_id}`` ConfigPort 查不到属正常
"""
cache_key = f"credentials:{account_id}"
try:
cached = await self._cache_port.get(cache_key)
if isinstance(cached, Some) and cached.unwrap():
return
except Exception as exc:
await self._logger.warn(
"credential preheat: cache read failed, continue to ConfigPort",
trace_id=trace_id,
account_id=account_id,
error=str(exc),
)
try:
config_value = await self._config_port.get(
"credentials",
scope=ConfigScope.ACCOUNT,
target=account_id,
)
except Exception:
# ConfigPort 无凭证记录(模式 A 渠道或未接入凭证),跳过预热
return
encrypted = config_value.value
if not encrypted:
return
try:
decrypted = decrypt_sensitive_fields(encrypted)
if decrypted:
await self._cache_port.set(cache_key, decrypted, ttl_seconds=None)
except Exception as exc:
await self._logger.warn(
"credential preheat: decrypt or cache write failed",
trace_id=trace_id,
account_id=account_id,
error=str(exc),
)
async def _restoreOnlineAccounts(self, trace_id: str) -> None: async def _restoreOnlineAccounts(self, trace_id: str) -> None:
"""重启恢复:扫描 DB 中 ACTIVE 状态账号,启动传输任务。 """重启恢复:扫描 DB 中 ACTIVE 状态账号,启动传输任务。
@ -681,9 +739,7 @@ class TransportManager:
account_key = self._make_account_key(channel_type, account_id) account_key = self._make_account_key(channel_type, account_id)
try: try:
if self._stream_worker is not None: if self._stream_worker is not None:
await self._stream_worker.stop_account( await self._stream_worker.stop_account(channel_type, account_id, reason="degraded")
channel_type, account_id, reason="degraded"
)
await self._startTransportForAccount( await self._startTransportForAccount(
channel_type=channel_type, channel_type=channel_type,
account_id=account_id, account_id=account_id,
@ -813,18 +869,12 @@ class TransportManager:
# 停止现有 Workerno-op if not running # 停止现有 Workerno-op if not running
if self._puller_worker is not None: if self._puller_worker is not None:
await self._puller_worker.stop_account( await self._puller_worker.stop_account(channel_type, account_id, reason="config_changed")
channel_type, account_id, reason="config_changed"
)
if self._stream_worker is not None: if self._stream_worker is not None:
await self._stream_worker.stop_account( await self._stream_worker.stop_account(channel_type, account_id, reason="config_changed")
channel_type, account_id, reason="config_changed"
)
# 启动新 Worker使用最新配置 # 启动新 Worker使用最新配置
await self._startTransportForAccount( await self._startTransportForAccount(channel_type, account_id, trace_id, source="config_changed")
channel_type, account_id, trace_id, source="config_changed"
)
async def _touchPluginStatus( async def _touchPluginStatus(
self, self,

View File

@ -126,9 +126,7 @@ class StreamWorker(BaseTransportWorker):
# P0-3: 每次重连前重新加载持久化游标与版本号,确保使用最新值。 # P0-3: 每次重连前重新加载持久化游标与版本号,确保使用最新值。
# _loadInitialCursor 抛 DependencyError 时由下方 except Exception # _loadInitialCursor 抛 DependencyError 时由下方 except Exception
# 转译为 TransportError(transient) 触发退避重连。 # 转译为 TransportError(transient) 触发退避重连。
persisted_cursor, account_version = await self._loadInitialCursor( persisted_cursor, account_version = await self._loadInitialCursor(channel_type, account_id)
channel_type, account_id
)
# cursor_holder 由适配器在 sync 事件时写入 sync cursor # cursor_holder 由适配器在 sync 事件时写入 sync cursor
# _receiveLoop 读取后批量持久化到 transport_cursor # _receiveLoop 读取后批量持久化到 transport_cursor
cursor_holder: dict[str, str] = {} cursor_holder: dict[str, str] = {}

View File

@ -23,10 +23,10 @@ from yuxi.channels.application.context.outbound_context import OutboundContext
from yuxi.channels.application.pipeline.control_plane.audit_context_builder import ( from yuxi.channels.application.pipeline.control_plane.audit_context_builder import (
AuditContextBuilder, AuditContextBuilder,
) )
from yuxi.channels.application.rate_limit_checker import RateLimitChecker
from yuxi.channels.application.pipeline.outbound.outbound_pipeline import ( from yuxi.channels.application.pipeline.outbound.outbound_pipeline import (
OutboundPipeline, OutboundPipeline,
) )
from yuxi.channels.application.rate_limit_checker import RateLimitChecker
from yuxi.channels.application.usecase.failure_detail import toFailureDetail from yuxi.channels.application.usecase.failure_detail import toFailureDetail
from yuxi.channels.contract.dtos.admin import AdminSendCmd, AdminSendResult from yuxi.channels.contract.dtos.admin import AdminSendCmd, AdminSendResult
from yuxi.channels.contract.dtos.channel import ChannelSession, ChannelType from yuxi.channels.contract.dtos.channel import ChannelSession, ChannelType

View File

@ -77,6 +77,11 @@ __all__ = ["InboundMessageService"]
# 当 ``ack_fallback_timeout_seconds`` 配置读取失败时使用此默认值FR-24 # 当 ``ack_fallback_timeout_seconds`` 配置读取失败时使用此默认值FR-24
_DEFAULT_ACK_FALLBACK_TIMEOUT_SECONDS = 60 _DEFAULT_ACK_FALLBACK_TIMEOUT_SECONDS = 60
# 同账号出站投递并发上限,防止任务堆积导致内存溢出。
# 不同会话的出站管道并行执行(会话级锁保证同会话串行),
# 超出上限时新任务排队等待信号量,自然形成背压。
_OUTBOUND_CONCURRENCY_PER_ACCOUNT = 5
class InboundMessageService: class InboundMessageService:
"""入站消息用例服务,实现 InboundMessagePort。 """入站消息用例服务,实现 InboundMessagePort。
@ -89,23 +94,22 @@ class InboundMessageService:
分阶段 ACKFR-24 分阶段 ACKFR-24
- AFTER_RECORD / AFTER_AGENT_DISPATCH ``ReplyStage`` 在入站管道 - AFTER_RECORD / AFTER_AGENT_DISPATCH ``ReplyStage`` 在入站管道
末尾决策 ACK 末尾决策 ACK
- AFTER_PERSIST出站管道成功后由本服务通过 ``AckDecisionMaker`` - AFTER_PERSIST出站管道成功后由后台投递任务通过 ``AckDecisionMaker``
幂等 ACKAC-52 幂等 ACKAC-52出站异步化后 ACK 延迟到后台任务完成
``receiveInbound`` 返回时 ``ack_decision`` ``pending``
- MANUAL保持 ``pending``由插件控制 ACK 时机插件忘记 ACK - MANUAL保持 ``pending``由插件控制 ACK 时机插件忘记 ACK
核心有兜底超时默认 60s配置项 ``ack_fallback_timeout_seconds`` 核心有兜底超时默认 60s配置项 ``ack_fallback_timeout_seconds``
自动 ACK 自动 ACK
- ACK 互斥 ``ReplyStage`` ACK``ctx.acked=True``出站 - ACK 互斥 ``ReplyStage`` ACK``ctx.acked=True``出站
失败时不覆盖为 NACK保持 ACK 语义不变 失败时不覆盖为 NACK保持 ACK 语义不变
入站管道创建 Agent 运行后``agent_run_id`` 非空且非静默本服务构造 出站投递异步化入站管道完成幂等记录 completed + ACK 已决策
``OutboundContext`` 并执行出站管道 Agent 响应投递至渠道侧FR-13 ``receiveInbound`` 将出站投递``_deliverAgentResponse`` /
``delivery_mode`` ``streaming_enabled`` 配置决定启用时走流式输出 ``_deliverSilentCommandResponse``通过 ``_scheduleOutboundDelivery``
路径stream-chunk / typing-indicator / typing-stop否则走持久化路径 异步触发立即返回释放 worker 循环接收下一条消息出站管道在后台
协程中执行不阻塞入站接收层同会话保序由 ``outbound:{conversation_id}``
静默命令``is_silent=True``携带响应内容时本服务构造最小 会话级锁保证同账号并发出站数由 ``_outbound_semaphores`` 信号量限制
``OutboundContext````delivery_mode="persistent"````agent_run_id=""`` 默认 5形成背压
执行出站管道投递命令响应不创建 AgentRunFR-15出站失败时通过
``ctx.outbound_error`` 透传错误至 ``InboundResult`` ACK 互斥
""" """
def __init__( def __init__(
@ -167,22 +171,36 @@ class InboundMessageService:
# MANUAL 兜底 ACK 任务集合M-15持有引用避免被 GC关闭时取消 # MANUAL 兜底 ACK 任务集合M-15持有引用避免被 GC关闭时取消
# 未完成任务,避免 sleep 60s 期间持有请求级 ctx 与连接资源。 # 未完成任务,避免 sleep 60s 期间持有请求级 ctx 与连接资源。
self._manual_fallback_tasks: set[asyncio.Task] = set() self._manual_fallback_tasks: set[asyncio.Task] = set()
# 出站投递异步任务集合:持有引用避免被 GCshutdown 时统一取消。
self._outbound_delivery_tasks: set[asyncio.Task] = set()
# 按账号隔离的信号量,限制同账号并发出站数,防止任务堆积。
# 键格式 "channel_type:account_id",不同账号互不影响。
self._outbound_semaphores: dict[str, asyncio.Semaphore] = {}
async def shutdown(self) -> None: async def shutdown(self) -> None:
"""关闭用例服务,取消所有未完成的 MANUAL 兜底 ACK 任务M-15 """关闭用例服务,取消所有未完成的后台任务M-15 + 出站投递)。
关闭流程取消 ``_manual_fallback_tasks`` 中未完成的任务避免任务 关闭流程取消 ``_manual_fallback_tasks`` ``_outbound_delivery_tasks``
在请求返回后仍持有 ``AckDecisionMaker`` 引用与配置端口连接 中未完成的任务避免任务在请求返回后仍持有引用与连接资源
完成的任务由 ``add_done_callback`` 自动从集合中移除 完成的任务由 ``add_done_callback`` 自动从集合中移除
出站投递任务可能正在执行出站管道 stream-chunk 阻塞等待 Agent
流式事件取消时通过 ``asyncio.CancelledError`` 中断由各阶段
finally 块释放资源如会话级锁
""" """
if not self._manual_fallback_tasks: # 合并两类待取消任务,统一 gather 避免分两次 await
return to_cancel: list[asyncio.Task] = []
if self._manual_fallback_tasks:
# 复制集合避免 cancel() 触发 done_callback 修改集合大小导致迭代异常 # 复制集合避免 cancel() 触发 done_callback 修改集合大小导致迭代异常
pending = list(self._manual_fallback_tasks) to_cancel.extend(self._manual_fallback_tasks)
for task in pending: if self._outbound_delivery_tasks:
to_cancel.extend(self._outbound_delivery_tasks)
if not to_cancel:
return
for task in to_cancel:
task.cancel() task.cancel()
# 等待所有任务完成取消,忽略 CancelledError # 等待所有任务完成取消,忽略 CancelledError
await asyncio.gather(*pending, return_exceptions=True) await asyncio.gather(*to_cancel, return_exceptions=True)
async def receiveInbound(self, cmd: InboundMessageCmd) -> InboundResult: async def receiveInbound(self, cmd: InboundMessageCmd) -> InboundResult:
"""接收并处理入站消息。 """接收并处理入站消息。
@ -196,14 +214,18 @@ class InboundMessageService:
执行 ``InboundPipeline.run(ctx)``管道失败时抛出错误触发事务 执行 ``InboundPipeline.run(ctx)``管道失败时抛出错误触发事务
回滚§10.1成功时正常退出触发提交 回滚§10.1成功时正常退出触发提交
4. 成功且创建 Agent 运行``agent_run_id`` 非空且非静默 4. 成功且创建 Agent 运行``agent_run_id`` 非空且非静默
调用 ``_deliverAgentResponse`` 桥接到出站管道投递 Agent 响应 通过 ``_scheduleOutboundDelivery`` 异步触发
FR-13 ``_deliverAgentResponse`` 桥接到出站管道投递 Agent 响应
FR-13出站在后台协程执行不阻塞入站接收层
5. 静默命令``is_silent=True``携带响应内容``ctx.command.response`` 5. 静默命令``is_silent=True``携带响应内容``ctx.command.response``
非空调用 ``_deliverSilentCommandResponse`` 通过出站管道 非空通过 ``_scheduleOutboundDelivery`` 异步触发
投递命令响应不创建 AgentRunFR-15 ``_deliverSilentCommandResponse`` 投递命令响应FR-15
6. MANUAL 策略下调度兜底自动 ACK 任务FR-24 6. MANUAL 策略下调度兜底自动 ACK 任务FR-24
7. 返回携带 ``ack_decision`` / ``agent_run_id`` / ``is_silent`` 7. 返回携带 ``ack_decision`` / ``agent_run_id`` / ``is_silent``
/ ``error`` / ``command_response`` ``InboundResult`` / ``error`` / ``command_response`` ``InboundResult``
出站异步化后``ack_decision`` 仅反映入站管道 ACK 状态
AFTER_RECORD / AFTER_AGENT_DISPATCH ack其他为 pending
``error`` 始终为 ``None``出站错误由后台任务处理
8. 异常处理HTTP 语义对齐``ChannelError`` 子类与翻译后的 8. 异常处理HTTP 语义对齐``ChannelError`` 子类与翻译后的
``OperationTimeoutError`` 向上抛由 ``unified_error_handler`` 统一 ``OperationTimeoutError`` 向上抛由 ``unified_error_handler`` 统一
映射真正未预期的非 ``ChannelError`` 异常返回 ``nack`` 映射真正未预期的非 ``ChannelError`` 异常返回 ``nack``
@ -340,21 +362,29 @@ class InboundMessageService:
exc_info=e, exc_info=e,
) )
# 桥接到出站管道:创建 Agent 运行后投递 Agent 响应至渠道侧FR-13 # 桥接到出站管道异步触发不阻塞入站接收层FR-13 / FR-15
# 出站投递在后台协程执行,同会话保序由 outbound:{conversation_id} 锁
# 保证同账号并发出站数由信号量限制。AFTER_PERSIST 策略下 ACK 由
# 后台任务完成后设置,此处 ack_decision 保持 pending。
if ctx.agent_run_id and not ctx.is_silent: if ctx.agent_run_id and not ctx.is_silent:
await self._deliverAgentResponse(ctx) self._scheduleOutboundDelivery(ctx, silent=False)
elif ctx.is_silent and ctx.command is not None and ctx.command.response is not None: elif ctx.is_silent and ctx.command is not None and ctx.command.response is not None:
# 静默命令响应投递:不创建 AgentRun通过出站管道投递命令响应FR-15 self._scheduleOutboundDelivery(ctx, silent=True)
await self._deliverSilentCommandResponse(ctx)
# MANUAL 策略兜底:插件忘记 ACK 时,调度延迟自动 ACKFR-24 # MANUAL 策略兜底:插件忘记 ACK 时,调度延迟自动 ACKFR-24
self._scheduleManualFallback(ctx) self._scheduleManualFallback(ctx)
# 出站异步化后InboundResult 仅反映入站管道 ACK 状态:
# - AFTER_RECORD / AFTER_AGENT_DISPATCHReplyStage 已设置 ctx.acked=True
# 和 ctx.ack_decision="ack",直接透传。
# - AFTER_PERSIST / MANUAL / NoneACK 由后台任务完成后设置,
# 此处保持 pending。出站错误由后台任务通过 _handleOutboundFailure
# 处理(幂等记录回退),不再通过 InboundResult 透传。
return InboundResult( return InboundResult(
ack_decision=ctx.ack_decision, ack_decision=ctx.ack_decision,
agent_run_id=ctx.agent_run_id, agent_run_id=ctx.agent_run_id,
is_silent=ctx.is_silent, is_silent=ctx.is_silent,
error=ctx.outbound_error, error=None,
command_response=(ctx.command.response if ctx.command is not None else None), command_response=(ctx.command.response if ctx.command is not None else None),
) )
@ -867,6 +897,70 @@ class InboundMessageService:
error=str(e), error=str(e),
) )
def _scheduleOutboundDelivery(self, ctx: InboundContext, *, silent: bool) -> None:
"""调度出站投递后台任务非阻塞FR-13 / FR-15
入站管道完成幂等记录 completed + ACK 已决策后立即触发出站投递
不阻塞 ``receiveInbound`` 返回释放 worker 循环接收下一条消息
任务在信号量控制下执行出站管道同账号并发出站数受信号量限制
任务生命周期加入 ``_outbound_delivery_tasks`` 集合持有引用避免 GC
完成时通过 ``add_done_callback`` 自动移除``shutdown`` 时统一取消
InboundContext 安全性每条消息有独立 ctx 实例``receiveInbound``
返回后无其他协程访问该 ctx``_deliverAgentResponse`` /
``_deliverSilentCommandResponse`` 仅读取不可变字段trace_id
agent_run_idchannel_sessionconversation_id 不访问
``ctx.tx``事务已在入站管道结束时提交
参数
ctx: 入站管道上下文携带 Agent 运行 ID渠道会话等字段
silent: ``True`` 投递静默命令响应``_deliverSilentCommandResponse``
``False`` 投递 Agent 响应``_deliverAgentResponse``
"""
task = asyncio.create_task(self._deliverOutboundAsync(ctx, silent=silent))
self._outbound_delivery_tasks.add(task)
task.add_done_callback(self._outbound_delivery_tasks.discard)
async def _deliverOutboundAsync(self, ctx: InboundContext, *, silent: bool) -> None:
"""出站投递后台任务,带信号量背压控制。
信号量按账号隔离``account_key = f"{channel_type}:{account_id}"``
不同账号互不影响信号量耗尽时任务排队等待自然形成背压防止
同账号无限制 ``create_task`` 导致内存溢出
``_deliverAgentResponse`` / ``_deliverSilentCommandResponse`` 内部已有
完整 try/except + ``_handleOutboundFailure``此处外层兜底防止异常
逃逸导致 ``create_task`` 静默失败
参数
ctx: 入站管道上下文
silent: ``True`` 投递静默命令响应``False`` 投递 Agent 响应
"""
account_key = f"{ctx.channel_type}:{ctx.account_id}"
sem = self._outbound_semaphores.get(account_key)
if sem is None:
sem = asyncio.Semaphore(_OUTBOUND_CONCURRENCY_PER_ACCOUNT)
self._outbound_semaphores[account_key] = sem
async with sem:
try:
if silent:
await self._deliverSilentCommandResponse(ctx)
else:
await self._deliverAgentResponse(ctx)
except Exception as e:
# _deliverAgentResponse 内部已有完整 try/except +
# _handleOutboundFailure此处兜底防止异常逃逸导致
# create_task 静默失败
await self._logger.exception(
"outbound delivery task crashed",
trace_id=ctx.trace_id,
agent_run_id=ctx.agent_run_id,
channel_type=str(ctx.channel_type),
account_id=ctx.account_id,
exc_info=e,
)
def _scheduleManualFallback(self, ctx: InboundContext) -> None: def _scheduleManualFallback(self, ctx: InboundContext) -> None:
"""调度 MANUAL 策略的兜底自动 ACKFR-24 """调度 MANUAL 策略的兜底自动 ACKFR-24

View File

@ -48,10 +48,12 @@ __all__ = ["CredentialService"]
#: ``fence:`` 约定(前缀 + ID #: ``fence:`` 约定(前缀 + ID
_CREDENTIAL_CACHE_KEY_PREFIX: str = "credentials:" _CREDENTIAL_CACHE_KEY_PREFIX: str = "credentials:"
#: 凭证缓存 TTL避免明文凭证长期驻留缓存H2-4 #: 凭证缓存 TTL``None`` 表示不过期。
#: 5 分钟窗口平衡缓存命中率与凭证轮换 / 撤销后的失效时延:超时后下次 #: 凭证缓存的失效由显式清理保证(``onAccountDisabled`` /
#: ``getCredentials`` 重新从 ConfigPort 解密回填,感知最新的撤销状态。 #: ``beforeAccountDelete`` 删除键,``_persistCredentials`` 覆写),
_CREDENTIAL_CACHE_TTL: int = 300 #: 不依赖 TTL 感知撤销状态。设 TTL 会导致运行时过期后插件客户端
#: (如 ILinkClient只读 CachePort 不回填,触发无限退避重试。
_CREDENTIAL_CACHE_TTL: int | None = None
#: Task 3 新增的 ACCOUNT 作用域配置键名。 #: Task 3 新增的 ACCOUNT 作用域配置键名。
_CREDENTIALS_KEY: str = "credentials" _CREDENTIALS_KEY: str = "credentials"

View File

@ -851,6 +851,7 @@ async def create_host_bootstrap(
circuit_breaker=channel_circuit_breaker, circuit_breaker=channel_circuit_breaker,
logger=logger, logger=logger,
message_deliverer=_deliver_inbound_message, message_deliverer=_deliver_inbound_message,
cache_port=core.cache_port,
) )
# 8. 构造 HealthAggregator注入 transport_manager 作为 transport_health_port # 8. 构造 HealthAggregator注入 transport_manager 作为 transport_health_port

View File

@ -426,7 +426,7 @@ def channel_entry(host: PluginHost, manifest: ChannelManifest) -> PluginManifest
host.registerAdapter("lifecycle", WeChatWocLifecycleAdapter(cache_port, logger_port)) host.registerAdapter("lifecycle", WeChatWocLifecycleAdapter(cache_port, logger_port))
host.registerAdapter("probeable", WeChatWocProbeableAdapter(client, persistence_port, logger_port)) host.registerAdapter("probeable", WeChatWocProbeableAdapter(client, persistence_port, logger_port))
host.registerAdapter("doctor", WeChatWocDoctorAdapter(client, logger_port)) host.registerAdapter("doctor", WeChatWocDoctorAdapter(client, logger_port))
host.registerAdapter("wizard", WeChatWocWizardAdapter(client, logger_port)) host.registerAdapter("wizard", WeChatWocWizardAdapter(client, config_port, logger_port, manifest.channel_type))
host.registerAdapter("whitelist", WeChatWocWhitelistAdapter(cache_port, logger_port)) host.registerAdapter("whitelist", WeChatWocWhitelistAdapter(cache_port, logger_port))
host.registerAdapter("directory", WeChatWocDirectoryAdapter(client, logger_port)) host.registerAdapter("directory", WeChatWocDirectoryAdapter(client, logger_port))

View File

@ -34,12 +34,12 @@ from uuid import uuid4
from yuxi.channels.contract.dtos.common import MessageContent, MessageFormat from yuxi.channels.contract.dtos.common import MessageContent, MessageFormat
from yuxi.channels.contract.dtos.config import ConfigScope from yuxi.channels.contract.dtos.config import ConfigScope
from yuxi.channels.contract.dtos.option import Some
from yuxi.channels.contract.dtos.outbound import ( from yuxi.channels.contract.dtos.outbound import (
BatchSendResult, BatchSendResult,
FormattedMessage, FormattedMessage,
) )
from yuxi.channels.contract.dtos.outbox import MultiPartReceipt from yuxi.channels.contract.dtos.outbox import MultiPartReceipt
from yuxi.channels.contract.dtos.option import Some
from yuxi.channels.contract.errors import ( from yuxi.channels.contract.errors import (
DependencyError, DependencyError,
NotFoundError, NotFoundError,

View File

@ -260,8 +260,10 @@ class WeChatWocStreamConnectorAdapter:
# 消息首次连接persisted_cursor 为空)回退到 sync # 消息首次连接persisted_cursor 为空)回退到 sync
# 事件 cursor。cursor 可能为 0从头补全 # 事件 cursor。cursor 可能为 0从头补全
# None/空字符串跳过。 # None/空字符串跳过。
backfill_cursor = persisted_cursor if persisted_cursor else ( backfill_cursor = (
str(cursor) if cursor is not None and cursor != "" else None persisted_cursor
if persisted_cursor
else (str(cursor) if cursor is not None and cursor != "" else None)
) )
if backfill_cursor is not None: if backfill_cursor is not None:
await self._backfill(account_id, backfill_cursor, buffer, handle.trace_id) await self._backfill(account_id, backfill_cursor, buffer, handle.trace_id)

View File

@ -39,6 +39,8 @@ from __future__ import annotations
import asyncio import asyncio
from typing import Any from typing import Any
from yuxi.channels.contract.dtos.channel import ChannelType
from yuxi.channels.contract.dtos.config import ConfigScope
from yuxi.channels.contract.dtos.wizard import ( from yuxi.channels.contract.dtos.wizard import (
WizardConfigPatch, WizardConfigPatch,
WizardField, WizardField,
@ -47,16 +49,19 @@ from yuxi.channels.contract.dtos.wizard import (
WizardStepResult, WizardStepResult,
) )
from yuxi.channels.contract.errors import ( from yuxi.channels.contract.errors import (
ConfigValidationError,
DependencyError, DependencyError,
NotFoundError,
NotImplementedError, NotImplementedError,
OperationTimeoutError, OperationTimeoutError,
RateLimitError, RateLimitError,
ValidationError, ValidationError,
) )
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.logger_port import LoggerPort
from ..woc_bridge_client import WocBridgeClient
from .._url_validators import _redact_url, _validate_bridge_url_host, _validate_bridge_url_scheme from .._url_validators import _redact_url, _validate_bridge_url_host, _validate_bridge_url_scheme
from ..woc_bridge_client import WocBridgeClient
# account_info 步骤 display_name 字段的占位提示文案 # account_info 步骤 display_name 字段的占位提示文案
_DISPLAY_NAME_PLACEHOLDER = "微信 WechatOnCloud 账号" _DISPLAY_NAME_PLACEHOLDER = "微信 WechatOnCloud 账号"
@ -69,18 +74,22 @@ _BRIDGE_TOKEN_PLACEHOLDER = "WOC_BRIDGE_API_TOKEN"
class WeChatWocWizardAdapter: class WeChatWocWizardAdapter:
"""微信 wechat_woc 安装向导适配器。 """微信 wechat_woc 安装向导适配器。
通过 DI 接收 ``WocBridgeClient`` / ``LoggerPort``不访问全局 settings 通过 DI 接收 ``WocBridgeClient`` / ``ConfigPort`` / ``LoggerPort``不访问
logger适配器仅执行步骤校验与配置补丁产出不含业务规则向导状态机 全局 settings logger适配器仅执行步骤校验与配置补丁产出不含业务规则
``WizardService`` 承载 向导状态机``WizardService`` 承载
""" """
def __init__( def __init__(
self, self,
client: WocBridgeClient, client: WocBridgeClient,
config_port: ConfigPort,
logger_port: LoggerPort, logger_port: LoggerPort,
channel_type: ChannelType,
) -> None: ) -> None:
self._client = client self._client = client
self._config_port = config_port
self._logger = logger_port self._logger = logger_port
self._channel_type = channel_type
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# WizardAdapter Protocol 实现 # WizardAdapter Protocol 实现
@ -174,9 +183,9 @@ class WeChatWocWizardAdapter:
- ``account_info``校验 ``display_name`` 为非空字符串产出含 - ``account_info``校验 ``display_name`` 为非空字符串产出含
``display_name`` 的配置补丁 ``display_name`` 的配置补丁
- ``bridge_config``校验 ``bridge_url`` 非空且使用 ``https://`` - ``bridge_config``校验 ``bridge_url`` 非空且使用 ``https://``
开发环境 localhost 豁免需预先配置 ``allow_insecure_localhost`` 开发环境 localhost 豁免需预先CHANNEL 作用域配置
wizard 阶段无 ConfigPort 访问默认强制 https追加 host 校验 ``allow_insecure_localhost=true``行为与 lifecycle 一致追加 host
拦截内网 IP SSRF 防护校验 ``bridge_token`` 非空产出含 校验拦截内网 IP SSRF 防护校验 ``bridge_token`` 非空产出含
``bridge_url`` ``bridge_token`` 的配置补丁 ``bridge_url`` ``bridge_token`` 的配置补丁
- ``verify`` ``applied_config`` ``bridge_url`` ``bridge_token`` - ``verify`` ``applied_config`` ``bridge_url`` ``bridge_token``
``WocBridgeClient.get_status_with_url`` 探活wizard 阶段账户未创建 ``WocBridgeClient.get_status_with_url`` 探活wizard 阶段账户未创建
@ -220,9 +229,10 @@ class WeChatWocWizardAdapter:
errors=("bridge_url_required",), errors=("bridge_url_required",),
) )
url = bridge_url.strip() url = bridge_url.strip()
allow_insecure = await self._allow_insecure_localhost()
try: try:
_validate_bridge_url_scheme(url, False) _validate_bridge_url_scheme(url, allow_insecure)
_validate_bridge_url_host(url, False) _validate_bridge_url_host(url, allow_insecure)
except ValidationError as exc: except ValidationError as exc:
return WizardStepResult( return WizardStepResult(
step_id="bridge_config", step_id="bridge_config",
@ -390,8 +400,9 @@ class WeChatWocWizardAdapter:
message="bridge_url must be a non-empty string", message="bridge_url must be a non-empty string",
) )
url = bridge_url.strip() url = bridge_url.strip()
_validate_bridge_url_scheme(url, False) allow_insecure = await self._allow_insecure_localhost()
_validate_bridge_url_host(url, False) _validate_bridge_url_scheme(url, allow_insecure)
_validate_bridge_url_host(url, allow_insecure)
bridge_token = values.get("bridge_token") bridge_token = values.get("bridge_token")
if not isinstance(bridge_token, str) or not bridge_token.strip(): if not isinstance(bridge_token, str) or not bridge_token.strip():
raise ValidationError( raise ValidationError(
@ -445,6 +456,28 @@ class WeChatWocWizardAdapter:
""" """
raise NotImplementedError(operation="buildOAuthAuthorizeUrl") raise NotImplementedError(operation="buildOAuthAuthorizeUrl")
async def _allow_insecure_localhost(self) -> bool:
"""读取 channel 作用域的 ``allow_insecure_localhost`` 配置。
lifecycle / lifecycle_adapter 保持一致仅在开发环境显式开启时
允许 ``http://localhost`` / ``127.0.0.1`` / ``::1`` bridge_url
读取失败时记录告警并默认关闭避免配置端口异常阻断 wizard 流程
"""
try:
value = await self._config_port.get(
"allow_insecure_localhost",
scope=ConfigScope.CHANNEL,
target=self._channel_type,
)
except (ConfigValidationError, NotFoundError) as exc:
await self._logger.warn(
"wechat_woc wizard: read allow_insecure_localhost failed, default to false",
channel_type=self._channel_type,
error=str(exc),
)
return False
return bool(value.value)
async def _fire_init_db_safely( async def _fire_init_db_safely(
self, self,
*, *,

View File

@ -28,8 +28,11 @@ client 实例化、LifecycleHandler 构造与 ``registerAdapter`` 调用。
``sse_heartbeat_interval_ms`` / ``sse_connect_timeout_ms`` / ``max_batch_size`` ``sse_heartbeat_interval_ms`` / ``sse_connect_timeout_ms`` / ``max_batch_size``
- ``outbound`` 接收 ``(client, config_port, logger_port)``需从 ConfigPort - ``outbound`` 接收 ``(client, config_port, logger_port)``需从 ConfigPort
CHANNEL 作用域读取 ``max_message_length``P2-2 CHANNEL 作用域读取 ``max_message_length``P2-2
- ``inbound`` / ``doctor`` / ``wizard`` / ``directory`` 接收 - ``wizard`` 接收 ``(client, config_port, logger_port, channel_type)``
``(client, logger_port)``仅做协议转换 需从 ConfigPort CHANNEL 作用域读取 ``allow_insecure_localhost`` 以兼容
开发环境 localhost http bridge_url
- ``inbound`` / ``doctor`` / ``directory`` 接收 ``(client, logger_port)``
仅做协议转换
- ``login`` 接收 ``(client, logger_port)``扫码状态由 ``QrLoginService`` - ``login`` 接收 ``(client, logger_port)``扫码状态由 ``QrLoginService``
承载适配器不缓存登录态 承载适配器不缓存登录态
- ``lifecycle`` 接收 ``(cache_port, logger_port)``账户删除/禁用时清理 - ``lifecycle`` 接收 ``(cache_port, logger_port)``账户删除/禁用时清理
@ -165,7 +168,7 @@ def channel_entry(host: PluginHost, manifest: ChannelManifest) -> PluginManifest
host.registerAdapter("lifecycle", WeChatWocLifecycleAdapter(cache_port, logger_port)) host.registerAdapter("lifecycle", WeChatWocLifecycleAdapter(cache_port, logger_port))
host.registerAdapter("probeable", WeChatWocProbeableAdapter(client, persistence_port, logger_port)) host.registerAdapter("probeable", WeChatWocProbeableAdapter(client, persistence_port, logger_port))
host.registerAdapter("doctor", WeChatWocDoctorAdapter(client, logger_port)) host.registerAdapter("doctor", WeChatWocDoctorAdapter(client, logger_port))
host.registerAdapter("wizard", WeChatWocWizardAdapter(client, logger_port)) host.registerAdapter("wizard", WeChatWocWizardAdapter(client, config_port, logger_port, manifest.channel_type))
host.registerAdapter("whitelist", WeChatWocWhitelistAdapter(cache_port, logger_port)) host.registerAdapter("whitelist", WeChatWocWhitelistAdapter(cache_port, logger_port))
host.registerAdapter("directory", WeChatWocDirectoryAdapter(client, logger_port)) host.registerAdapter("directory", WeChatWocDirectoryAdapter(client, logger_port))

View File

@ -57,6 +57,49 @@ _USER_AGENT_TEMPLATE = "yuxi-channels-wechat-woc/{version}"
# 放大延迟(最坏 15 次 HTTP 请求 + 35s+ 等待)。 # 放大延迟(最坏 15 次 HTTP 请求 + 35s+ 等待)。
_HTTP_NETWORK_ERROR_MAX_ATTEMPTS = 2 _HTTP_NETWORK_ERROR_MAX_ATTEMPTS = 2
class _ReentrantAsyncLock:
"""可重入的 asyncio 锁。
同一协程asyncio.Task可多次 acquire仅在首次实际获取底层
``asyncio.Lock``后续重入仅增加计数release 时递减计数计数归零
才释放底层锁不同协程之间仍保持互斥
用途wechat_woc 要求同账号 send_text/image/file 串行调用但一次
出站管道可能在同一协程内连续调用多个 send_*长文本分片多媒体
流式续发非可重入锁会导致同协程死锁因此需要可重入语义
"""
def __init__(self) -> None:
self._lock = asyncio.Lock()
self._owner: asyncio.Task | None = None
self._count = 0
async def acquire(self) -> None:
task = asyncio.current_task()
if task is self._owner:
self._count += 1
return
await self._lock.acquire()
self._owner = task
self._count = 1
def release(self) -> None:
task = asyncio.current_task()
if task is not self._owner:
raise RuntimeError("release unlocked lock")
self._count -= 1
if self._count == 0:
self._owner = None
self._lock.release()
async def __aenter__(self) -> None:
await self.acquire()
async def __aexit__(self, exc_type: Any, exc: Any, tb: Any) -> None:
self.release()
# bridge 能力缓存 TTL与 CapabilityProof.cache_ttl 默认值一致 # bridge 能力缓存 TTL与 CapabilityProof.cache_ttl 默认值一致
_CAPABILITIES_CACHE_TTL_SECONDS = 300 _CAPABILITIES_CACHE_TTL_SECONDS = 300
# 能力协商失败时保守默认值的短 TTL避免 bridge 持续不可用时每次出站 # 能力协商失败时保守默认值的短 TTL避免 bridge 持续不可用时每次出站
@ -265,6 +308,9 @@ class WocBridgeClient:
# onUnload 时 close_all_sse 先于 detach_http_client 关闭所有 SSE 长连接, # onUnload 时 close_all_sse 先于 detach_http_client 关闭所有 SSE 长连接,
# 避免独立 httpx client不复用共享连接池成为孤儿连接P1-6 # 避免独立 httpx client不复用共享连接池成为孤儿连接P1-6
self._active_sse_handles: dict[str, SseStreamHandle] = {} self._active_sse_handles: dict[str, SseStreamHandle] = {}
# 按账号隔离的发送锁,保证同账号 send_text/image/file 逐一调用 bridge。
# 使用可重入锁,避免同一出站协程内多次调用 send_* 时死锁。
self._send_locks: dict[str, _ReentrantAsyncLock] = {}
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# 连接池管理 # 连接池管理
@ -348,6 +394,22 @@ class WocBridgeClient:
) )
return False return False
def _get_send_lock(self, account_id: str) -> _ReentrantAsyncLock:
"""获取指定账号的发送锁,保证同账号出站请求逐一调用 bridge。
``account_id`` 隔离不同账号互不阻塞同一账号内 ``send_text`` /
``send_image`` / ``send_file`` 串行执行锁为可重入锁同一协程内
连续调用多个 send_* 不会死锁
@consistency
- 幂等重复获取返回同一锁对象
"""
lock = self._send_locks.get(account_id)
if lock is None:
lock = _ReentrantAsyncLock()
self._send_locks[account_id] = lock
return lock
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# 凭证读取 # 凭证读取
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@ -1232,6 +1294,7 @@ class WocBridgeClient:
to_wxid=to_wxid, to_wxid=to_wxid,
content_length=len(content), content_length=len(content),
) )
async with self._get_send_lock(account_id):
resp = await self._execute_http( resp = await self._execute_http(
"POST", "POST",
"/api/send/text", "/api/send/text",
@ -1275,6 +1338,7 @@ class WocBridgeClient:
to_wxid=to_wxid, to_wxid=to_wxid,
file_path=file_path, file_path=file_path,
) )
async with self._get_send_lock(account_id):
resp = await self._execute_http( resp = await self._execute_http(
"POST", "POST",
"/api/send/image", "/api/send/image",
@ -1318,6 +1382,7 @@ class WocBridgeClient:
to_wxid=to_wxid, to_wxid=to_wxid,
file_path=file_path, file_path=file_path,
) )
async with self._get_send_lock(account_id):
resp = await self._execute_http( resp = await self._execute_http(
"POST", "POST",
"/api/send/file", "/api/send/file",