diff --git a/backend/package/yuxi/channels/adapters/__init__.py b/backend/package/yuxi/channels/adapters/__init__.py index 589e4251..0eff09b3 100644 --- a/backend/package/yuxi/channels/adapters/__init__.py +++ b/backend/package/yuxi/channels/adapters/__init__.py @@ -6,11 +6,15 @@ 装配策略分两类(§4.12): - **请求级 / 无状态适配器**(14 个)经 ``create_driven_adapters(db, - outbox_config, redis_client, arq_pool, execution_port)`` factory 构造: - 需要共享 ``AsyncSession`` 的适配器(``ChannelPersistenceAdapter`` / - ``ConversationAdapter`` / ``SqlAlchemyTransactionAdapter``)共享同一 ``db``; - 无状态适配器(Logger / Tracer)使用模块级全局实例;``identity_resolver`` - 默认为 ``None``,由插件注册时注入。 + session_factory, outbox_config, redis_client, arq_pool, execution_port)`` + factory 构造: + ``ChannelPersistenceAdapter`` 为无状态适配器,注入 ``session_factory`` + (``Callable[[], AsyncSession]``),通过 ``_session_scope(tx)`` 按需获取 + session(``tx`` 非空时复用应用层主事务 session,``tx`` 为 ``None`` 时自主 + 创建并提交);``ConversationAdapter`` / ``SqlAlchemyTransactionAdapter`` + 为请求级事务边界适配器,共享同一 ``db`` 会话以保证事务一致性;无状态适配器 + (Logger / Tracer)使用模块级全局实例;``identity_resolver`` 默认为 + ``None``,由插件注册时注入。 - **独立装配路径适配器**(4 个)不经过工厂,不进入 ``DrivenAdapters`` 聚合: ``ContentReviewRepositoryAdapter`` / ``DefaultContentModerationAdapter`` 是 应用级单例(在 ``factory.create_host_bootstrap`` 构造并注册 DI); @@ -26,7 +30,7 @@ from __future__ import annotations -from collections.abc import Mapping +from collections.abc import Callable, Mapping from arq import ArqRedis from redis.asyncio import Redis @@ -72,6 +76,7 @@ _tracer_adapter = InMemoryTracerAdapter(_logger_adapter) def create_driven_adapters( db: AsyncSession, + session_factory: Callable[[], AsyncSession], outbox_config: OutboxConfig, redis_client: Redis, arq_pool: ArqRedis, @@ -81,20 +86,25 @@ def create_driven_adapters( ) -> DrivenAdapters: """创建被驱动适配器聚合实例。 - 请求级适配器(``ChannelPersistenceAdapter`` / ``ConversationAdapter`` / - ``SqlAlchemyTransactionAdapter````) - 共享同一 ``db`` 会话以保证事务一致性;无状态适配器(Logger / Tracer) - 使用模块级全局实例;其余适配器按需构造。``ConversationAdapter`` 仅依赖 - 共享 ``db`` 会话(合并策略开关决策已上移至 usecase 层,``ConfigPort`` / - ``PersistencePort`` 死依赖已移除)。``RedisConfigAdapter`` / - ``RedisCacheAdapter`` 接受 ``redis_client`` 执行配置读写与缓存操作; - ``ARQQueueAdapter`` 接受 ``arq_pool`` 入队;``AgentRunAdapter`` 接受 - ``execution_port`` 委托 Agent 运行技术操作(构造注入,INV-5 / ADP-001 / - ADP-005 / ADP-022)。``identity_resolver`` 默认为 ``None``,由插件注册时注入。 + ``ChannelPersistenceAdapter`` 为无状态适配器,注入 ``session_factory`` + (``Callable[[], AsyncSession]``),通过 ``_session_scope(tx)`` 按需获取 + session:``tx`` 非空时复用应用层主事务 session(C-I1 透传),``tx`` 为 + ``None`` 时通过 ``session_factory()`` 创建独立 session 并自主提交。 + ``ConversationAdapter`` / ``SqlAlchemyTransactionAdapter`` 为请求级事务 + 边界适配器,共享同一 ``db`` 会话以保证事务一致性;无状态适配器 + (Logger / Tracer)使用模块级全局实例;其余适配器按需构造。 + ``RedisConfigAdapter`` / ``RedisCacheAdapter`` 接受 ``redis_client`` 执行 + 配置读写与缓存操作;``ARQQueueAdapter`` 接受 ``arq_pool`` 入队; + ``AgentRunAdapter`` 接受 ``execution_port`` 委托 Agent 运行技术操作 + (构造注入,INV-5 / ADP-001 / ADP-005 / ADP-022)。``identity_resolver`` + 默认为 ``None``,由插件注册时注入。 Args: - db: SQLAlchemy 异步会话,由框架层注入,persistence / conversation / - transaction 适配器共享此会话。 + db: SQLAlchemy 异步会话(请求级),由框架层注入,conversation / + transaction 适配器共享此会话以保证事务一致性。 + session_factory: ``Callable[[], AsyncSession]``,注入到 + ``ChannelPersistenceAdapter`` 供 ``_session_scope(None)`` 创建 + 独立 session(无状态适配器不持有请求级 ``db``)。 outbox_config: 发件箱配置,注入到 ``ChannelPersistenceAdapter`` 替代 直接读取全局 ``app_config``(§6.1 应用服务层禁止依赖具体技术适配器)。 redis_client: ``redis.asyncio.Redis`` 客户端实例,注入到 @@ -120,7 +130,7 @@ def create_driven_adapters( key_to_scope_map=key_to_scope_map, declared_keys=declared_keys, ) - persistence = ChannelPersistenceAdapter(db, outbox_config=outbox_config, logger=_logger_adapter) + persistence = ChannelPersistenceAdapter(session_factory, outbox_config=outbox_config, logger=_logger_adapter) # 服务账号适配器单例:仅依赖 logger,共享注入到 AgentRunAdapter / 管道阶段 / AccountHandler service_account = ServiceAccountAdapter(logger=_logger_adapter) # Agent 渠道访问配置适配器单例:仅依赖 logger,注入到 agent-run-enqueue 阶段 diff --git a/backend/package/yuxi/channels/adapters/agent_run_adapter.py b/backend/package/yuxi/channels/adapters/agent_run_adapter.py index 343f725f..b2d594de 100644 --- a/backend/package/yuxi/channels/adapters/agent_run_adapter.py +++ b/backend/package/yuxi/channels/adapters/agent_run_adapter.py @@ -435,7 +435,7 @@ class AgentRunAdapter(AgentRunPort): 委托 ``execution_port.streamAgentRunEvents`` 输出流式事件并转换为 ``StreamEvent`` DTO。``current_uid`` 通过 ``execution_port.getRunUid`` 从 run 记录查询获取(创建 run 时由 - ``channel_sender_id`` 写入),确保 ``streamAgentRunEvents`` 内部的 + ``service_account.uid`` 写入),确保 ``streamAgentRunEvents`` 内部的 权限校验通过。 Args: @@ -451,10 +451,13 @@ class AgentRunAdapter(AgentRunPort): """ try: # 通过 execution_port 查询 run 记录的 uid(创建时由 - # channel_sender_id 写入),确保 streamAgentRunEvents 内部 + # service_account.uid 写入),确保 streamAgentRunEvents 内部 # get_run_for_user 权限校验通过。current_uid="" 会导致查不到 - # 记录而报错。 - current_uid = await self._execution_port.getRunUid(run_id.value, None) + # 记录而报错。需通过 _session_scope 获取独立会话,不能传 None + # 否则 AgentRunRepository(None).get_run() 会在 None.execute() + # 处抛 AttributeError。 + async with self._session_scope(None) as session: + current_uid = await self._execution_port.getRunUid(run_id.value, session) async for event in self._execution_port.streamAgentRunEvents( run_id=run_id.value, diff --git a/backend/package/yuxi/channels/adapters/channel_persistence_adapter.py b/backend/package/yuxi/channels/adapters/channel_persistence_adapter.py index 978ea452..c47b3ebc 100644 --- a/backend/package/yuxi/channels/adapters/channel_persistence_adapter.py +++ b/backend/package/yuxi/channels/adapters/channel_persistence_adapter.py @@ -17,6 +17,8 @@ from __future__ import annotations import asyncio import uuid +from collections.abc import AsyncIterator, Callable +from contextlib import asynccontextmanager from datetime import datetime, timedelta from typing import TYPE_CHECKING, Any @@ -243,57 +245,60 @@ class ChannelPersistenceAdapter( def __init__( self, - db: AsyncSession, + session_factory: Callable[[], AsyncSession], outbox_config: OutboxConfig, logger: LoggerPort, ) -> None: - """初始化适配器,注入共享数据库会话并创建 Repositories 聚合。 + """初始化适配器,注入 session 工厂(不持有 session 实例)。 + + 适配器为无状态协议转换器,可安全注册为应用级单例。每次方法调用 + 通过 ``_session_scope(tx)`` 按需获取 session:``tx`` 非空时复用 + 应用层主事务 session(C-I1 透传),``tx`` 为 ``None`` 时通过 + ``session_factory`` 创建独立 session 并自主提交。 Args: - db: SQLAlchemy 异步会话,所有仓储共享以保证事务一致性。 + session_factory: 可调用对象,调用返回 ``AsyncSession`` 实例。 outbox_config: 发件箱配置,提供 ``ttl_seconds`` / ``max_retry`` 等参数,替代直接读取全局 ``app_config``(§6.1 应用服务层 禁止依赖具体技术适配器)。 logger: 日志被驱动端口,用于记录 ``ping`` 等 **降级返回** 路径 上的故障,以及所有 ``except`` 兜底块中的异常信息。 """ - self._db = db + self._session_factory = session_factory self._outbox_config = outbox_config - self._repos: Repositories = create_repositories(db) self._logger: LoggerPort = logger - async def aclose(self) -> None: - """关闭内部持有的 AsyncSession。 + @asynccontextmanager + async def _session_scope( + self, tx: TransactionContext | None + ) -> AsyncIterator[tuple[AsyncSession, Repositories, bool]]: + """统一 session 解析与生命周期管理。 - ``ChannelPersistenceAdapter``、``ConversationAdapter`` 与 - ``SqlAlchemyTransactionAdapter`` 在 factory 中共用同一 ``AsyncSession``, - 由本适配器统一负责关闭,避免重复关闭。关闭后该会话不可再用, - 调用方应在插件卸载 / 宿主关停时通过 ``DrivenAdapters.close()`` 触发。 + - ``tx`` 非空且 ``get_session()`` 返回 session:复用应用层主事务 + session(C-I1 透传),``commit=False``(应用层统一提交), + 不 close(应用层管理生命周期)。 + - ``tx`` 为 ``None`` 或无 session:通过 ``session_factory`` 创建 + 独立 session,``commit=True``(方法级自主提交),异常 rollback, + finally close 归还连接。 - 异常向上传播,由调用方负责捕获记录(符合契约层不吞异常的职责划分)。 - - Raises: - SQLAlchemyError: 底层会话关闭时的数据库故障。 - """ - await self._db.close() - - async def releaseSession(self) -> None: - """释放当前会话占用的连接(``PersistenceHealthPort.releaseSession``)。 - - 对共享 ``AsyncSession`` 执行 ``commit`` 以结束 SQLAlchemy 2.0 autobegin - 开启的隐式事务,连接随之归还连接池。无活动事务时为 no-op(``commit`` - 对干净 session 安全)。后台扫描器在每轮扫描结束时调用本方法,避免 - 应用级共享会话因只读查询残留隐式事务而长期占用连接导致连接池耗尽。 - - 不关闭会话本身(``aclose`` 负责最终关闭),扫描器下一轮仍可复用。 - - Raises: - DependencyError: 提交事务时的数据库故障(翻译后向上传播)。 + Yields: + (session, repos, commit):session 供显式操作,repos 供仓储调用, + commit 供 repo 决定是否提交。 """ + if tx is not None: + session = tx.get_session() + if session is not None: + yield session, create_repositories(session), False + return + session = self._session_factory() try: - await self._db.commit() - except SQLAlchemyError as exc: - raise self._translate_db_error(exc, "persistence_session") from exc + try: + yield session, create_repositories(session), True + except Exception: + await session.rollback() + raise + finally: + await session.close() def _translate_db_error(self, exc: Exception, resource: str) -> Error: """将数据库异常翻译为契约层错误。 @@ -317,21 +322,6 @@ class ChannelPersistenceAdapter( return ConflictError(resource) return DependencyError(resource, Error(str(exc))) - def _should_commit(self, tx: TransactionContext | None) -> bool: - """判断是否由适配器自主提交事务。 - - 事务边界由应用层控制:``tx`` 非空时加入应用层事务, - 适配器 **不得** 自主提交(返回 ``False``);``tx`` 为 ``None`` 时 - 按单方法提交(返回 ``True``,向后兼容)。 - - Args: - tx: 事务上下文,``None`` 表示无应用层事务。 - - Returns: - ``True`` 表示适配器自主提交,``False`` 表示由应用层提交。 - """ - return tx is None - # ═══════════════════════════════════════════════════════════════════════════ # 渠道账户 CRUD # ═══════════════════════════════════════════════════════════════════════════ @@ -354,7 +344,6 @@ class ChannelPersistenceAdapter( ConflictError: 账户 ID 重复(唯一约束冲突)。 DependencyError: 数据库故障。 """ - commit = self._should_commit(tx) data: dict[str, Any] = { "channel_type": cmd.channel_type, "account_id": cmd.account_id, @@ -367,26 +356,27 @@ class ChannelPersistenceAdapter( if cmd.service_user_uid is not None: data["service_user_uid"] = cmd.service_user_uid data = channel_account_for_write(data) - try: - orm = await self._repos.account.create(data, commit=commit) - return orm_to_channel_account(orm) - except IntegrityError as exc: - raise self._translate_db_error(exc, "channel_account") from exc - except SQLAlchemyError as exc: - raise self._translate_db_error(exc, "channel_account") from exc - except (NotFoundError, ConflictError, DependencyError): - # 契约层错误:mappers 翻译的 DependencyError 与本适配器抛出的 NotFoundError/ConflictError - # 直接放行,不二次翻译,保留原始异常链与错误码 - raise - except Exception as exc: - # 兜底:mappers 调用过程中可能抛出 TypeError 等非契约异常,翻译为 DependencyError - # 防止穿透至核心层(INV-7);保留异常链以便追踪 - await self._logger.error( - "channel_account_save_failed", - resource="channel_account", - error=str(exc), - ) - raise DependencyError("channel_account", Error(str(exc))) from exc + async with self._session_scope(tx) as (_, repos, commit): + try: + orm = await repos.account.create(data, commit=commit) + return orm_to_channel_account(orm) + except IntegrityError as exc: + raise self._translate_db_error(exc, "channel_account") from exc + except SQLAlchemyError as exc: + raise self._translate_db_error(exc, "channel_account") from exc + except (NotFoundError, ConflictError, DependencyError): + # 契约层错误:mappers 翻译的 DependencyError 与本适配器抛出的 NotFoundError/ConflictError + # 直接放行,不二次翻译,保留原始异常链与错误码 + raise + except Exception as exc: + # 兜底:mappers 调用过程中可能抛出 TypeError 等非契约异常,翻译为 DependencyError + # 防止穿透至核心层(INV-7);保留异常链以便追踪 + await self._logger.error( + "channel_account_save_failed", + resource="channel_account", + error=str(exc), + ) + raise DependencyError("channel_account", Error(str(exc))) from exc async def updateChannelAccount( self, @@ -407,56 +397,56 @@ class ChannelPersistenceAdapter( ConflictError: 并发冲突。 DependencyError: 数据库故障。 """ - commit = self._should_commit(tx) - try: - orm = await self._repos.account.get_by_type_and_account(cmd.channel_type, cmd.account_id, for_update=True) - if orm is None: - raise NotFoundError("channel_account", cmd.account_id) - if orm.version != cmd.expected_version: - raise ConflictError("channel_account") - data: dict = {} - if cmd.display_name is not None: - data["display_name"] = cmd.display_name - if cmd.config is not None: - data["config"] = cmd.config - if cmd.enabled is not None: - data["enabled"] = cmd.enabled - if cmd.status is not None: - data["status"] = cmd.status.value - if cmd.transport_cursor is not None: - data["transport_cursor"] = cmd.transport_cursor - if cmd.last_rotated_at is not None: - data["last_rotated_at"] = cmd.last_rotated_at - if cmd.onboarding_status is not None: - data["onboarding_status"] = cmd.onboarding_status - if cmd.credential_ref is not None: - data["credential_ref"] = cmd.credential_ref - if cmd.credential_version is not None: - data["credential_version"] = cmd.credential_version - if cmd.last_error is not None: - data["last_error"] = cmd.last_error - data["last_error_at"] = utc_now_naive() - data = channel_account_for_write(data) - orm.version += 1 - updated = await self._repos.account.update(orm, data, commit=commit) - return orm_to_channel_account(updated) - except IntegrityError as exc: - raise self._translate_db_error(exc, "channel_account") from exc - except SQLAlchemyError as exc: - raise self._translate_db_error(exc, "channel_account") from exc - except (NotFoundError, ConflictError, DependencyError): - # 契约层错误:mappers 翻译的 DependencyError 与本适配器抛出的 NotFoundError/ConflictError - # 直接放行,不二次翻译,保留原始异常链与错误码 - raise - except Exception as exc: - # 兜底:mappers 调用过程中可能抛出 TypeError 等非契约异常,翻译为 DependencyError - # 防止穿透至核心层(INV-7);保留异常链以便追踪 - await self._logger.error( - "channel_account_update_failed", - resource="channel_account", - error=str(exc), - ) - raise DependencyError("channel_account", Error(str(exc))) from exc + async with self._session_scope(tx) as (_, repos, commit): + try: + orm = await repos.account.get_by_type_and_account(cmd.channel_type, cmd.account_id, for_update=True) + if orm is None: + raise NotFoundError("channel_account", cmd.account_id) + if orm.version != cmd.expected_version: + raise ConflictError("channel_account") + data: dict = {} + if cmd.display_name is not None: + data["display_name"] = cmd.display_name + if cmd.config is not None: + data["config"] = cmd.config + if cmd.enabled is not None: + data["enabled"] = cmd.enabled + if cmd.status is not None: + data["status"] = cmd.status.value + if cmd.transport_cursor is not None: + data["transport_cursor"] = cmd.transport_cursor + if cmd.last_rotated_at is not None: + data["last_rotated_at"] = cmd.last_rotated_at + if cmd.onboarding_status is not None: + data["onboarding_status"] = cmd.onboarding_status + if cmd.credential_ref is not None: + data["credential_ref"] = cmd.credential_ref + if cmd.credential_version is not None: + data["credential_version"] = cmd.credential_version + if cmd.last_error is not None: + data["last_error"] = cmd.last_error + data["last_error_at"] = utc_now_naive() + data = channel_account_for_write(data) + orm.version += 1 + updated = await repos.account.update(orm, data, commit=commit) + return orm_to_channel_account(updated) + except IntegrityError as exc: + raise self._translate_db_error(exc, "channel_account") from exc + except SQLAlchemyError as exc: + raise self._translate_db_error(exc, "channel_account") from exc + except (NotFoundError, ConflictError, DependencyError): + # 契约层错误:mappers 翻译的 DependencyError 与本适配器抛出的 NotFoundError/ConflictError + # 直接放行,不二次翻译,保留原始异常链与错误码 + raise + except Exception as exc: + # 兜底:mappers 调用过程中可能抛出 TypeError 等非契约异常,翻译为 DependencyError + # 防止穿透至核心层(INV-7);保留异常链以便追踪 + await self._logger.error( + "channel_account_update_failed", + resource="channel_account", + error=str(exc), + ) + raise DependencyError("channel_account", Error(str(exc))) from exc async def getChannelAccount(self, channel_type: ChannelType, account_id: str) -> ChannelAccount | None: """按类型与 ID 查询渠道账户,返回解密后的配置;不存在返回 None。 @@ -464,28 +454,29 @@ class ChannelPersistenceAdapter( Raises: DependencyError: 数据库故障。 """ - try: - orm = await self._repos.account.get_by_type_and_account(channel_type, account_id) - if orm is None: - return None - return orm_to_channel_account(orm) - except IntegrityError as exc: - raise self._translate_db_error(exc, "channel_account") from exc - except SQLAlchemyError as exc: - raise self._translate_db_error(exc, "channel_account") from exc - except (NotFoundError, ConflictError, DependencyError): - # 契约层错误:mappers 翻译的 DependencyError 与本适配器抛出的 NotFoundError/ConflictError - # 直接放行,不二次翻译,保留原始异常链与错误码 - raise - except Exception as exc: - # 兜底:mappers 调用过程中可能抛出 TypeError 等非契约异常,翻译为 DependencyError - # 防止穿透至核心层(INV-7);保留异常链以便追踪 - await self._logger.error( - "channel_account_get_failed", - resource="channel_account", - error=str(exc), - ) - raise DependencyError("channel_account", Error(str(exc))) from exc + async with self._session_scope(None) as (_, repos, _): + try: + orm = await repos.account.get_by_type_and_account(channel_type, account_id) + if orm is None: + return None + return orm_to_channel_account(orm) + except IntegrityError as exc: + raise self._translate_db_error(exc, "channel_account") from exc + except SQLAlchemyError as exc: + raise self._translate_db_error(exc, "channel_account") from exc + except (NotFoundError, ConflictError, DependencyError): + # 契约层错误:mappers 翻译的 DependencyError 与本适配器抛出的 NotFoundError/ConflictError + # 直接放行,不二次翻译,保留原始异常链与错误码 + raise + except Exception as exc: + # 兜底:mappers 调用过程中可能抛出 TypeError 等非契约异常,翻译为 DependencyError + # 防止穿透至核心层(INV-7);保留异常链以便追踪 + await self._logger.error( + "channel_account_get_failed", + resource="channel_account", + error=str(exc), + ) + raise DependencyError("channel_account", Error(str(exc))) from exc async def listChannelAccounts( self, @@ -498,30 +489,31 @@ class ChannelPersistenceAdapter( Raises: DependencyError: 数据库故障。 """ - try: - orms = await self._repos.account.list( - channel_type=channel_type if channel_type else None, - limit=limit, - offset=offset, - ) - return tuple(orm_to_channel_account(orm) for orm in orms) - except IntegrityError as exc: - raise self._translate_db_error(exc, "channel_account") from exc - except SQLAlchemyError as exc: - raise self._translate_db_error(exc, "channel_account") from exc - except (NotFoundError, ConflictError, DependencyError): - # 契约层错误:mappers 翻译的 DependencyError 与本适配器抛出的 NotFoundError/ConflictError - # 直接放行,不二次翻译,保留原始异常链与错误码 - raise - except Exception as exc: - # 兜底:mappers 调用过程中可能抛出 TypeError 等非契约异常,翻译为 DependencyError - # 防止穿透至核心层(INV-7);保留异常链以便追踪 - await self._logger.error( - "channel_account_list_failed", - resource="channel_account", - error=str(exc), - ) - raise DependencyError("channel_account", Error(str(exc))) from exc + async with self._session_scope(None) as (_, repos, _): + try: + orms = await repos.account.list( + channel_type=channel_type if channel_type else None, + limit=limit, + offset=offset, + ) + return tuple(orm_to_channel_account(orm) for orm in orms) + except IntegrityError as exc: + raise self._translate_db_error(exc, "channel_account") from exc + except SQLAlchemyError as exc: + raise self._translate_db_error(exc, "channel_account") from exc + except (NotFoundError, ConflictError, DependencyError): + # 契约层错误:mappers 翻译的 DependencyError 与本适配器抛出的 NotFoundError/ConflictError + # 直接放行,不二次翻译,保留原始异常链与错误码 + raise + except Exception as exc: + # 兜底:mappers 调用过程中可能抛出 TypeError 等非契约异常,翻译为 DependencyError + # 防止穿透至核心层(INV-7);保留异常链以便追踪 + await self._logger.error( + "channel_account_list_failed", + resource="channel_account", + error=str(exc), + ) + raise DependencyError("channel_account", Error(str(exc))) from exc async def findAccountsByFilter( self, @@ -537,32 +529,33 @@ class ChannelPersistenceAdapter( Raises: DependencyError: 数据库故障。 """ - try: - stmt = select(ChannelAccountORM).where(ChannelAccountORM.is_deleted == 0) - if filter.channel_type is not None: - stmt = stmt.where(ChannelAccountORM.channel_type == filter.channel_type) - if filter.status is not None: - stmt = stmt.where(ChannelAccountORM.status == filter.status.value) - result = await self._db.execute(stmt) - orms = result.scalars().all() - return tuple(orm_to_channel_account(orm) for orm in orms) - except IntegrityError as exc: - raise self._translate_db_error(exc, "channel_account") from exc - except SQLAlchemyError as exc: - raise self._translate_db_error(exc, "channel_account") from exc - except (NotFoundError, ConflictError, DependencyError): - # 契约层错误:mappers 翻译的 DependencyError 与本适配器抛出的 NotFoundError/ConflictError - # 直接放行,不二次翻译,保留原始异常链与错误码 - raise - except Exception as exc: - # 兜底:mappers 调用过程中可能抛出 TypeError 等非契约异常,翻译为 DependencyError - # 防止穿透至核心层(INV-7);保留异常链以便追踪 - await self._logger.error( - "channel_account_find_accounts_by_filter_failed", - resource="channel_account", - error=str(exc), - ) - raise DependencyError("channel_account", Error(str(exc))) from exc + async with self._session_scope(None) as (db, _, _): + try: + stmt = select(ChannelAccountORM).where(ChannelAccountORM.is_deleted == 0) + if filter.channel_type is not None: + stmt = stmt.where(ChannelAccountORM.channel_type == filter.channel_type) + if filter.status is not None: + stmt = stmt.where(ChannelAccountORM.status == filter.status.value) + result = await db.execute(stmt) + orms = result.scalars().all() + return tuple(orm_to_channel_account(orm) for orm in orms) + except IntegrityError as exc: + raise self._translate_db_error(exc, "channel_account") from exc + except SQLAlchemyError as exc: + raise self._translate_db_error(exc, "channel_account") from exc + except (NotFoundError, ConflictError, DependencyError): + # 契约层错误:mappers 翻译的 DependencyError 与本适配器抛出的 NotFoundError/ConflictError + # 直接放行,不二次翻译,保留原始异常链与错误码 + raise + except Exception as exc: + # 兜底:mappers 调用过程中可能抛出 TypeError 等非契约异常,翻译为 DependencyError + # 防止穿透至核心层(INV-7);保留异常链以便追踪 + await self._logger.error( + "channel_account_find_accounts_by_filter_failed", + resource="channel_account", + error=str(exc), + ) + raise DependencyError("channel_account", Error(str(exc))) from exc async def deleteChannelAccount( self, @@ -580,17 +573,17 @@ class ChannelPersistenceAdapter( Raises: DependencyError: 数据库故障。 """ - commit = self._should_commit(tx) - try: - orm = await self._repos.account.get_by_type_and_account(channel_type, account_id) - if orm is None: - return False - await self._repos.account.delete_by_id(orm.id, commit=commit) - return True - except IntegrityError as exc: - raise self._translate_db_error(exc, "channel_account") from exc - except SQLAlchemyError as exc: - raise self._translate_db_error(exc, "channel_account") from exc + async with self._session_scope(tx) as (_, repos, commit): + try: + orm = await repos.account.get_by_type_and_account(channel_type, account_id) + if orm is None: + return False + await repos.account.delete_by_id(orm.id, commit=commit) + return True + except IntegrityError as exc: + raise self._translate_db_error(exc, "channel_account") from exc + except SQLAlchemyError as exc: + raise self._translate_db_error(exc, "channel_account") from exc async def updateLastHealthCheckAt( self, @@ -602,17 +595,17 @@ class ChannelPersistenceAdapter( 不走 CAS 路径(诊断字段,非业务状态变更)。账户不存在时返回 False。 """ - commit = self._should_commit(tx) - try: - orm = await self._repos.account.get_by_type_and_account(channel_type, account_id) - if orm is None: - return False - await self._repos.account.update_last_health_check_at(orm.id, commit=commit) - return True - except IntegrityError as exc: - raise self._translate_db_error(exc, "channel_account") from exc - except SQLAlchemyError as exc: - raise self._translate_db_error(exc, "channel_account") from exc + async with self._session_scope(tx) as (_, repos, commit): + try: + orm = await repos.account.get_by_type_and_account(channel_type, account_id) + if orm is None: + return False + await repos.account.update_last_health_check_at(orm.id, commit=commit) + return True + except IntegrityError as exc: + raise self._translate_db_error(exc, "channel_account") from exc + except SQLAlchemyError as exc: + raise self._translate_db_error(exc, "channel_account") from exc async def updatePluginStatus( self, @@ -626,17 +619,17 @@ class ChannelPersistenceAdapter( 由 ``TransportManager`` 在账户传输任务 start/stop/error 后调用。 不走 CAS 路径(单写者:TransportManager)。账户不存在时返回 False。 """ - commit = self._should_commit(tx) - try: - orm = await self._repos.account.get_by_type_and_account(channel_type, account_id) - if orm is None: - return False - await self._repos.account.update_plugin_status(orm.id, plugin_status, commit=commit) - return True - except IntegrityError as exc: - raise self._translate_db_error(exc, "channel_account") from exc - except SQLAlchemyError as exc: - raise self._translate_db_error(exc, "channel_account") from exc + async with self._session_scope(tx) as (_, repos, commit): + try: + orm = await repos.account.get_by_type_and_account(channel_type, account_id) + if orm is None: + return False + await repos.account.update_plugin_status(orm.id, plugin_status, commit=commit) + return True + except IntegrityError as exc: + raise self._translate_db_error(exc, "channel_account") from exc + except SQLAlchemyError as exc: + raise self._translate_db_error(exc, "channel_account") from exc # ═══════════════════════════════════════════════════════════════════════════ # 渠道会话 CRUD @@ -661,48 +654,48 @@ class ChannelPersistenceAdapter( ConflictError: 唯一约束冲突。 DependencyError: 数据库故障。 """ - commit = self._should_commit(tx) - try: - account_orm = await self._repos.account.get_by_type_and_account(cmd.channel_type, cmd.account_id) - if account_orm is None: - raise NotFoundError("channel_account", cmd.account_id) - conversation_id_int: int | None = None - if cmd.conversation_id: - try: - conversation_id_int = int(cmd.conversation_id) - except (TypeError, ValueError) as exc: - raise NotFoundError("conversation", cmd.conversation_id) from exc - data = { - "session_id": uuid.uuid4().hex, - "account_id": account_orm.id, - "channel_type": cmd.channel_type, - "peer_id": cmd.peer_id, - "chat_type": cmd.chat_type, - "conversation_id": conversation_id_int, - "unified_identity_id": cmd.unified_identity_id, - "owner_peer_id": cmd.owner_peer_id, - "is_temporary": cmd.is_temporary, - "last_message_at": cmd.last_message_at, - } - orm = await self._repos.session.create(data, commit=commit) - return orm_to_channel_session(orm, account_orm) - except IntegrityError as exc: - raise self._translate_db_error(exc, "channel_session") from exc - except SQLAlchemyError as exc: - raise self._translate_db_error(exc, "channel_session") from exc - except (NotFoundError, ConflictError, DependencyError): - # 契约层错误:mappers 翻译的 DependencyError 与本适配器抛出的 NotFoundError/ConflictError - # 直接放行,不二次翻译,保留原始异常链与错误码 - raise - except Exception as exc: - # 兜底:mappers 调用过程中可能抛出 TypeError 等非契约异常,翻译为 DependencyError - # 防止穿透至核心层(INV-7);保留异常链以便追踪 - await self._logger.error( - "channel_session_save_failed", - resource="channel_session", - error=str(exc), - ) - raise DependencyError("channel_session", Error(str(exc))) from exc + async with self._session_scope(tx) as (_, repos, commit): + try: + account_orm = await repos.account.get_by_type_and_account(cmd.channel_type, cmd.account_id) + if account_orm is None: + raise NotFoundError("channel_account", cmd.account_id) + conversation_id_int: int | None = None + if cmd.conversation_id: + try: + conversation_id_int = int(cmd.conversation_id) + except (TypeError, ValueError) as exc: + raise NotFoundError("conversation", cmd.conversation_id) from exc + data = { + "session_id": uuid.uuid4().hex, + "account_id": account_orm.id, + "channel_type": cmd.channel_type, + "peer_id": cmd.peer_id, + "chat_type": cmd.chat_type, + "conversation_id": conversation_id_int, + "unified_identity_id": cmd.unified_identity_id, + "owner_peer_id": cmd.owner_peer_id, + "is_temporary": cmd.is_temporary, + "last_message_at": cmd.last_message_at, + } + orm = await repos.session.create(data, commit=commit) + return orm_to_channel_session(orm, account_orm) + except IntegrityError as exc: + raise self._translate_db_error(exc, "channel_session") from exc + except SQLAlchemyError as exc: + raise self._translate_db_error(exc, "channel_session") from exc + except (NotFoundError, ConflictError, DependencyError): + # 契约层错误:mappers 翻译的 DependencyError 与本适配器抛出的 NotFoundError/ConflictError + # 直接放行,不二次翻译,保留原始异常链与错误码 + raise + except Exception as exc: + # 兜底:mappers 调用过程中可能抛出 TypeError 等非契约异常,翻译为 DependencyError + # 防止穿透至核心层(INV-7);保留异常链以便追踪 + await self._logger.error( + "channel_session_save_failed", + resource="channel_session", + error=str(exc), + ) + raise DependencyError("channel_session", Error(str(exc))) from exc async def updateChannelSession( self, @@ -722,58 +715,58 @@ class ChannelPersistenceAdapter( ConflictError: 并发冲突。 DependencyError: 数据库故障。 """ - commit = self._should_commit(tx) - try: - orm = await self._repos.session.get_by_session_id(cmd.session_id, for_update=True) - if orm is None: - raise NotFoundError("channel_session", cmd.session_id) - if cmd.expected_version is None: - # 乐观锁缺口可见化:调用方未传 expected_version,跳过版本检查 - # (向后兼容),但记录 WARN 以便追溯潜在的丢失更新风险。 - await self._logger.warn( - "channel_session_update_without_expected_version", + async with self._session_scope(tx) as (_, repos, commit): + try: + orm = await repos.session.get_by_session_id(cmd.session_id, for_update=True) + if orm is None: + raise NotFoundError("channel_session", cmd.session_id) + if cmd.expected_version is None: + # 乐观锁缺口可见化:调用方未传 expected_version,跳过版本检查 + # (向后兼容),但记录 WARN 以便追溯潜在的丢失更新风险。 + await self._logger.warn( + "channel_session_update_without_expected_version", + resource="channel_session", + session_id=cmd.session_id, + ) + elif orm.version != cmd.expected_version: + raise ConflictError("channel_session") + data: dict = {} + if cmd.conversation_id is not None: + try: + data["conversation_id"] = int(cmd.conversation_id) + except (TypeError, ValueError) as exc: + raise NotFoundError("conversation", cmd.conversation_id) from exc + if cmd.unified_identity_id is not None: + data["unified_identity_id"] = cmd.unified_identity_id + if cmd.owner_peer_id is not None: + data["owner_peer_id"] = cmd.owner_peer_id + if cmd.is_temporary is not None: + data["is_temporary"] = cmd.is_temporary + if cmd.closed_at is not None: + data["closed_at"] = cmd.closed_at + data["version"] = orm.version + 1 + updated = await repos.session.update(orm, data, commit=commit) + account_orm = await repos.account.get_by_id(orm.account_id) + if account_orm is None: + raise NotFoundError("channel_account", str(orm.account_id)) + return orm_to_channel_session(updated, account_orm) + except IntegrityError as exc: + raise self._translate_db_error(exc, "channel_session") from exc + except SQLAlchemyError as exc: + raise self._translate_db_error(exc, "channel_session") from exc + except (NotFoundError, ConflictError, DependencyError): + # 契约层错误:mappers 翻译的 DependencyError 与本适配器抛出的 NotFoundError/ConflictError + # 直接放行,不二次翻译,保留原始异常链与错误码 + raise + except Exception as exc: + # 兜底:mappers 调用过程中可能抛出 TypeError 等非契约异常,翻译为 DependencyError + # 防止穿透至核心层(INV-7);保留异常链以便追踪 + await self._logger.error( + "channel_session_update_failed", resource="channel_session", - session_id=cmd.session_id, + error=str(exc), ) - elif orm.version != cmd.expected_version: - raise ConflictError("channel_session") - data: dict = {} - if cmd.conversation_id is not None: - try: - data["conversation_id"] = int(cmd.conversation_id) - except (TypeError, ValueError) as exc: - raise NotFoundError("conversation", cmd.conversation_id) from exc - if cmd.unified_identity_id is not None: - data["unified_identity_id"] = cmd.unified_identity_id - if cmd.owner_peer_id is not None: - data["owner_peer_id"] = cmd.owner_peer_id - if cmd.is_temporary is not None: - data["is_temporary"] = cmd.is_temporary - if cmd.closed_at is not None: - data["closed_at"] = cmd.closed_at - data["version"] = orm.version + 1 - updated = await self._repos.session.update(orm, data, commit=commit) - account_orm = await self._repos.account.get_by_id(orm.account_id) - if account_orm is None: - raise NotFoundError("channel_account", str(orm.account_id)) - return orm_to_channel_session(updated, account_orm) - except IntegrityError as exc: - raise self._translate_db_error(exc, "channel_session") from exc - except SQLAlchemyError as exc: - raise self._translate_db_error(exc, "channel_session") from exc - except (NotFoundError, ConflictError, DependencyError): - # 契约层错误:mappers 翻译的 DependencyError 与本适配器抛出的 NotFoundError/ConflictError - # 直接放行,不二次翻译,保留原始异常链与错误码 - raise - except Exception as exc: - # 兜底:mappers 调用过程中可能抛出 TypeError 等非契约异常,翻译为 DependencyError - # 防止穿透至核心层(INV-7);保留异常链以便追踪 - await self._logger.error( - "channel_session_update_failed", - resource="channel_session", - error=str(exc), - ) - raise DependencyError("channel_session", Error(str(exc))) from exc + raise DependencyError("channel_session", Error(str(exc))) from exc async def getChannelSession(self, session_id: str) -> ChannelSession | None: """按 session_id 查询未软删除的渠道会话;不存在返回 None。 @@ -781,31 +774,32 @@ class ChannelPersistenceAdapter( Raises: DependencyError: 数据库故障。 """ - try: - orm = await self._repos.session.get_by_session_id(session_id) - if orm is None: - return None - account_orm = await self._repos.account.get_by_id(orm.account_id) - if account_orm is None: - raise NotFoundError("channel_account", str(orm.account_id)) - return orm_to_channel_session(orm, account_orm) - except IntegrityError as exc: - raise self._translate_db_error(exc, "channel_session") from exc - except SQLAlchemyError as exc: - raise self._translate_db_error(exc, "channel_session") from exc - except (NotFoundError, ConflictError, DependencyError): - # 契约层错误:mappers 翻译的 DependencyError 与本适配器抛出的 NotFoundError/ConflictError - # 直接放行,不二次翻译,保留原始异常链与错误码 - raise - except Exception as exc: - # 兜底:mappers 调用过程中可能抛出 TypeError 等非契约异常,翻译为 DependencyError - # 防止穿透至核心层(INV-7);保留异常链以便追踪 - await self._logger.error( - "channel_session_get_failed", - resource="channel_session", - error=str(exc), - ) - raise DependencyError("channel_session", Error(str(exc))) from exc + async with self._session_scope(None) as (_, repos, _): + try: + orm = await repos.session.get_by_session_id(session_id) + if orm is None: + return None + account_orm = await repos.account.get_by_id(orm.account_id) + if account_orm is None: + raise NotFoundError("channel_account", str(orm.account_id)) + return orm_to_channel_session(orm, account_orm) + except IntegrityError as exc: + raise self._translate_db_error(exc, "channel_session") from exc + except SQLAlchemyError as exc: + raise self._translate_db_error(exc, "channel_session") from exc + except (NotFoundError, ConflictError, DependencyError): + # 契约层错误:mappers 翻译的 DependencyError 与本适配器抛出的 NotFoundError/ConflictError + # 直接放行,不二次翻译,保留原始异常链与错误码 + raise + except Exception as exc: + # 兜底:mappers 调用过程中可能抛出 TypeError 等非契约异常,翻译为 DependencyError + # 防止穿透至核心层(INV-7);保留异常链以便追踪 + await self._logger.error( + "channel_session_get_failed", + resource="channel_session", + error=str(exc), + ) + raise DependencyError("channel_session", Error(str(exc))) from exc async def getChannelSessionByPeer( self, @@ -820,31 +814,32 @@ class ChannelPersistenceAdapter( Raises: DependencyError: 数据库故障。 """ - try: - account_orm = await self._repos.account.get_by_type_and_account(channel_type, account_id) - if account_orm is None: - return None - orm = await self._repos.session.get_by_account_and_peer(account_orm.id, peer_id) - if orm is None: - return None - return orm_to_channel_session(orm, account_orm) - except IntegrityError as exc: - raise self._translate_db_error(exc, "channel_session") from exc - except SQLAlchemyError as exc: - raise self._translate_db_error(exc, "channel_session") from exc - except (NotFoundError, ConflictError, DependencyError): - # 契约层错误:mappers 翻译的 DependencyError 与本适配器抛出的 NotFoundError/ConflictError - # 直接放行,不二次翻译,保留原始异常链与错误码 - raise - except Exception as exc: - # 兜底:mappers 调用过程中可能抛出 TypeError 等非契约异常,翻译为 DependencyError - # 防止穿透至核心层(INV-7);保留异常链以便追踪 - await self._logger.error( - "channel_session_get_by_peer_failed", - resource="channel_session", - error=str(exc), - ) - raise DependencyError("channel_session", Error(str(exc))) from exc + async with self._session_scope(None) as (_, repos, _): + try: + account_orm = await repos.account.get_by_type_and_account(channel_type, account_id) + if account_orm is None: + return None + orm = await repos.session.get_by_account_and_peer(account_orm.id, peer_id) + if orm is None: + return None + return orm_to_channel_session(orm, account_orm) + except IntegrityError as exc: + raise self._translate_db_error(exc, "channel_session") from exc + except SQLAlchemyError as exc: + raise self._translate_db_error(exc, "channel_session") from exc + except (NotFoundError, ConflictError, DependencyError): + # 契约层错误:mappers 翻译的 DependencyError 与本适配器抛出的 NotFoundError/ConflictError + # 直接放行,不二次翻译,保留原始异常链与错误码 + raise + except Exception as exc: + # 兜底:mappers 调用过程中可能抛出 TypeError 等非契约异常,翻译为 DependencyError + # 防止穿透至核心层(INV-7);保留异常链以便追踪 + await self._logger.error( + "channel_session_get_by_peer_failed", + resource="channel_session", + error=str(exc), + ) + raise DependencyError("channel_session", Error(str(exc))) from exc async def getChannelSessionByConversationId( self, @@ -859,41 +854,42 @@ class ChannelPersistenceAdapter( DependencyError: 数据库故障。 """ try: + conversation_id_int = int(conversation_id) + except (TypeError, ValueError): + return None + async with self._session_scope(None) as (db, repos, _): try: - conversation_id_int = int(conversation_id) - except (TypeError, ValueError): - return None - stmt = ( - select(ChannelSessionORM) - .where(ChannelSessionORM.is_deleted == 0) - .where(ChannelSessionORM.conversation_id == conversation_id_int) - .limit(1) - ) - result = await self._db.execute(stmt) - orm = result.scalars().first() - if orm is None: - return None - account_orm = await self._repos.account.get_by_id(orm.account_id) - if account_orm is None: - raise NotFoundError("channel_account", str(orm.account_id)) - return orm_to_channel_session(orm, account_orm) - except IntegrityError as exc: - raise self._translate_db_error(exc, "channel_session") from exc - except SQLAlchemyError as exc: - raise self._translate_db_error(exc, "channel_session") from exc - except (NotFoundError, ConflictError, DependencyError): - # 契约层错误:mappers 翻译的 DependencyError 与本适配器抛出的 NotFoundError/ConflictError - # 直接放行,不二次翻译,保留原始异常链与错误码 - raise - except Exception as exc: - # 兜底:mappers 调用过程中可能抛出 TypeError 等非契约异常,翻译为 DependencyError - # 防止穿透至核心层(INV-7);保留异常链以便追踪 - await self._logger.error( - "channel_session_get_by_conversation_id_failed", - resource="channel_session", - error=str(exc), - ) - raise DependencyError("channel_session", Error(str(exc))) from exc + stmt = ( + select(ChannelSessionORM) + .where(ChannelSessionORM.is_deleted == 0) + .where(ChannelSessionORM.conversation_id == conversation_id_int) + .limit(1) + ) + result = await db.execute(stmt) + orm = result.scalars().first() + if orm is None: + return None + account_orm = await repos.account.get_by_id(orm.account_id) + if account_orm is None: + raise NotFoundError("channel_account", str(orm.account_id)) + return orm_to_channel_session(orm, account_orm) + except IntegrityError as exc: + raise self._translate_db_error(exc, "channel_session") from exc + except SQLAlchemyError as exc: + raise self._translate_db_error(exc, "channel_session") from exc + except (NotFoundError, ConflictError, DependencyError): + # 契约层错误:mappers 翻译的 DependencyError 与本适配器抛出的 NotFoundError/ConflictError + # 直接放行,不二次翻译,保留原始异常链与错误码 + raise + except Exception as exc: + # 兜底:mappers 调用过程中可能抛出 TypeError 等非契约异常,翻译为 DependencyError + # 防止穿透至核心层(INV-7);保留异常链以便追踪 + await self._logger.error( + "channel_session_get_by_conversation_id_failed", + resource="channel_session", + error=str(exc), + ) + raise DependencyError("channel_session", Error(str(exc))) from exc async def listChannelSessionsByConversationId( self, @@ -910,34 +906,35 @@ class ChannelPersistenceAdapter( DependencyError: 数据库故障。 """ try: + conversation_id_int = int(conversation_id) + except (TypeError, ValueError): + return () + async with self._session_scope(None) as (db, _, _): try: - conversation_id_int = int(conversation_id) - except (TypeError, ValueError): - return () - stmt = select(ChannelSessionORM).where(ChannelSessionORM.conversation_id == conversation_id_int) - result = await self._db.execute(stmt) - orms = result.scalars().all() - # 批量查询关联账户 ORM,避免 N+1 查询 - account_ids = {orm.account_id for orm in orms} - account_orms_map: dict[int, ChannelAccountORM] = {} - if account_ids: - stmt = select(ChannelAccountORM).where(ChannelAccountORM.id.in_(account_ids)) - result = await self._db.execute(stmt) - account_orms_map = {a.id: a for a in result.scalars().all()} - return tuple(orm_to_channel_session(orm, account_orms_map.get(orm.account_id)) for orm in orms) - except IntegrityError as exc: - raise self._translate_db_error(exc, "channel_session") from exc - except SQLAlchemyError as exc: - raise self._translate_db_error(exc, "channel_session") from exc - except (NotFoundError, ConflictError, DependencyError): - raise - except Exception as exc: - await self._logger.error( - "channel_session_list_by_conversation_id_failed", - resource="channel_session", - error=str(exc), - ) - raise DependencyError("channel_session", Error(str(exc))) from exc + stmt = select(ChannelSessionORM).where(ChannelSessionORM.conversation_id == conversation_id_int) + result = await db.execute(stmt) + orms = result.scalars().all() + # 批量查询关联账户 ORM,避免 N+1 查询 + account_ids = {orm.account_id for orm in orms} + account_orms_map: dict[int, ChannelAccountORM] = {} + if account_ids: + stmt = select(ChannelAccountORM).where(ChannelAccountORM.id.in_(account_ids)) + result = await db.execute(stmt) + account_orms_map = {a.id: a for a in result.scalars().all()} + return tuple(orm_to_channel_session(orm, account_orms_map.get(orm.account_id)) for orm in orms) + except IntegrityError as exc: + raise self._translate_db_error(exc, "channel_session") from exc + except SQLAlchemyError as exc: + raise self._translate_db_error(exc, "channel_session") from exc + except (NotFoundError, ConflictError, DependencyError): + raise + except Exception as exc: + await self._logger.error( + "channel_session_list_by_conversation_id_failed", + resource="channel_session", + error=str(exc), + ) + raise DependencyError("channel_session", Error(str(exc))) from exc async def listChannelSessions( self, @@ -963,45 +960,46 @@ class ChannelPersistenceAdapter( Raises: DependencyError: 数据库故障。 """ - try: - orms = await self._repos.session.list( - channel_type=channel_type if channel_type else None, - peer_id=peer_id, - start_time=created_after, - end_time=created_before, - owner_peer_id=owner_peer_id, - status=status.value if status else None, - last_message_after=last_message_after, - last_message_before=last_message_before, - abnormal=abnormal, - limit=limit, - offset=offset, - ) - # 批量查询关联账户 ORM,避免 N+1 查询 - account_ids = {orm.account_id for orm in orms} - account_orms_map: dict[int, ChannelAccountORM] = {} - if account_ids: - stmt = select(ChannelAccountORM).where(ChannelAccountORM.id.in_(account_ids)) - result = await self._db.execute(stmt) - account_orms_map = {a.id: a for a in result.scalars().all()} - return tuple(orm_to_channel_session(orm, account_orms_map.get(orm.account_id)) for orm in orms) - except IntegrityError as exc: - raise self._translate_db_error(exc, "channel_session") from exc - except SQLAlchemyError as exc: - raise self._translate_db_error(exc, "channel_session") from exc - except (NotFoundError, ConflictError, DependencyError): - # 契约层错误:mappers 翻译的 DependencyError 与本适配器抛出的 NotFoundError/ConflictError - # 直接放行,不二次翻译,保留原始异常链与错误码 - raise - except Exception as exc: - # 兜底:mappers 调用过程中可能抛出 TypeError 等非契约异常,翻译为 DependencyError - # 防止穿透至核心层(INV-7);保留异常链以便追踪 - await self._logger.error( - "channel_session_list_failed", - resource="channel_session", - error=str(exc), - ) - raise DependencyError("channel_session", Error(str(exc))) from exc + async with self._session_scope(None) as (db, repos, _): + try: + orms = await repos.session.list( + channel_type=channel_type if channel_type else None, + peer_id=peer_id, + start_time=created_after, + end_time=created_before, + owner_peer_id=owner_peer_id, + status=status.value if status else None, + last_message_after=last_message_after, + last_message_before=last_message_before, + abnormal=abnormal, + limit=limit, + offset=offset, + ) + # 批量查询关联账户 ORM,避免 N+1 查询 + account_ids = {orm.account_id for orm in orms} + account_orms_map: dict[int, ChannelAccountORM] = {} + if account_ids: + stmt = select(ChannelAccountORM).where(ChannelAccountORM.id.in_(account_ids)) + result = await db.execute(stmt) + account_orms_map = {a.id: a for a in result.scalars().all()} + return tuple(orm_to_channel_session(orm, account_orms_map.get(orm.account_id)) for orm in orms) + except IntegrityError as exc: + raise self._translate_db_error(exc, "channel_session") from exc + except SQLAlchemyError as exc: + raise self._translate_db_error(exc, "channel_session") from exc + except (NotFoundError, ConflictError, DependencyError): + # 契约层错误:mappers 翻译的 DependencyError 与本适配器抛出的 NotFoundError/ConflictError + # 直接放行,不二次翻译,保留原始异常链与错误码 + raise + except Exception as exc: + # 兜底:mappers 调用过程中可能抛出 TypeError 等非契约异常,翻译为 DependencyError + # 防止穿透至核心层(INV-7);保留异常链以便追踪 + await self._logger.error( + "channel_session_list_failed", + resource="channel_session", + error=str(exc), + ) + raise DependencyError("channel_session", Error(str(exc))) from exc async def countChannelSessions( self, @@ -1022,31 +1020,32 @@ class ChannelPersistenceAdapter( Raises: DependencyError: 数据库故障。 """ - try: - return await self._repos.session.count( - channel_type=channel_type if channel_type else None, - peer_id=peer_id, - start_time=created_after, - end_time=created_before, - owner_peer_id=owner_peer_id, - status=status.value if status else None, - last_message_after=last_message_after, - last_message_before=last_message_before, - abnormal=abnormal, - ) - except IntegrityError as exc: - raise self._translate_db_error(exc, "channel_session") from exc - except SQLAlchemyError as exc: - raise self._translate_db_error(exc, "channel_session") from exc - except (NotFoundError, ConflictError, DependencyError): - raise - except Exception as exc: - await self._logger.error( - "channel_session_count_failed", - resource="channel_session", - error=str(exc), - ) - raise DependencyError("channel_session", Error(str(exc))) from exc + async with self._session_scope(None) as (_, repos, _): + try: + return await repos.session.count( + channel_type=channel_type if channel_type else None, + peer_id=peer_id, + start_time=created_after, + end_time=created_before, + owner_peer_id=owner_peer_id, + status=status.value if status else None, + last_message_after=last_message_after, + last_message_before=last_message_before, + abnormal=abnormal, + ) + except IntegrityError as exc: + raise self._translate_db_error(exc, "channel_session") from exc + except SQLAlchemyError as exc: + raise self._translate_db_error(exc, "channel_session") from exc + except (NotFoundError, ConflictError, DependencyError): + raise + except Exception as exc: + await self._logger.error( + "channel_session_count_failed", + resource="channel_session", + error=str(exc), + ) + raise DependencyError("channel_session", Error(str(exc))) from exc async def findSessionsByFilter( self, @@ -1070,51 +1069,52 @@ class ChannelPersistenceAdapter( Raises: DependencyError: 数据库故障。 """ - try: - stmt = select(ChannelSessionORM).where(ChannelSessionORM.is_deleted == 0) - if filter.channel_type is not None: - stmt = stmt.where(ChannelSessionORM.channel_type == filter.channel_type) - if filter.unified_identity_id is not None: - stmt = stmt.where(ChannelSessionORM.unified_identity_id == filter.unified_identity_id) - if filter.inactive_before is not None: - inactive_before_dt = _coerceFilterDateTime(filter.inactive_before) - # last_message_at 为 NULL(从未收消息)或早于 inactive_before 均视为非活跃 - stmt = stmt.where( - (ChannelSessionORM.last_message_at.is_(None)) - | (ChannelSessionORM.last_message_at < inactive_before_dt) + async with self._session_scope(None) as (db, _, _): + try: + stmt = select(ChannelSessionORM).where(ChannelSessionORM.is_deleted == 0) + if filter.channel_type is not None: + stmt = stmt.where(ChannelSessionORM.channel_type == filter.channel_type) + if filter.unified_identity_id is not None: + stmt = stmt.where(ChannelSessionORM.unified_identity_id == filter.unified_identity_id) + if filter.inactive_before is not None: + inactive_before_dt = _coerceFilterDateTime(filter.inactive_before) + # last_message_at 为 NULL(从未收消息)或早于 inactive_before 均视为非活跃 + stmt = stmt.where( + (ChannelSessionORM.last_message_at.is_(None)) + | (ChannelSessionORM.last_message_at < inactive_before_dt) + ) + if filter.status is not None: + if filter.status == SessionStatus.ACTIVE: + stmt = stmt.where(ChannelSessionORM.closed_at.is_(None)) + else: + stmt = stmt.where(ChannelSessionORM.closed_at.isnot(None)) + result = await db.execute(stmt) + orms = result.scalars().all() + # 批量查询关联账户 ORM,避免 N+1 查询 + account_ids = {orm.account_id for orm in orms} + account_orms_map: dict[int, ChannelAccountORM] = {} + if account_ids: + stmt = select(ChannelAccountORM).where(ChannelAccountORM.id.in_(account_ids)) + result = await db.execute(stmt) + account_orms_map = {a.id: a for a in result.scalars().all()} + return tuple(orm_to_channel_session(orm, account_orms_map.get(orm.account_id)) for orm in orms) + except IntegrityError as exc: + raise self._translate_db_error(exc, "channel_session") from exc + except SQLAlchemyError as exc: + raise self._translate_db_error(exc, "channel_session") from exc + except (NotFoundError, ConflictError, DependencyError): + # 契约层错误:mappers 翻译的 DependencyError 与本适配器抛出的 NotFoundError/ConflictError + # 直接放行,不二次翻译,保留原始异常链与错误码 + raise + except Exception as exc: + # 兜底:mappers 调用过程中可能抛出 TypeError 等非契约异常,翻译为 DependencyError + # 防止穿透至核心层(INV-7);保留异常链以便追踪 + await self._logger.error( + "channel_session_find_sessions_by_filter_failed", + resource="channel_session", + error=str(exc), ) - if filter.status is not None: - if filter.status == SessionStatus.ACTIVE: - stmt = stmt.where(ChannelSessionORM.closed_at.is_(None)) - else: - stmt = stmt.where(ChannelSessionORM.closed_at.isnot(None)) - result = await self._db.execute(stmt) - orms = result.scalars().all() - # 批量查询关联账户 ORM,避免 N+1 查询 - account_ids = {orm.account_id for orm in orms} - account_orms_map: dict[int, ChannelAccountORM] = {} - if account_ids: - stmt = select(ChannelAccountORM).where(ChannelAccountORM.id.in_(account_ids)) - result = await self._db.execute(stmt) - account_orms_map = {a.id: a for a in result.scalars().all()} - return tuple(orm_to_channel_session(orm, account_orms_map.get(orm.account_id)) for orm in orms) - except IntegrityError as exc: - raise self._translate_db_error(exc, "channel_session") from exc - except SQLAlchemyError as exc: - raise self._translate_db_error(exc, "channel_session") from exc - except (NotFoundError, ConflictError, DependencyError): - # 契约层错误:mappers 翻译的 DependencyError 与本适配器抛出的 NotFoundError/ConflictError - # 直接放行,不二次翻译,保留原始异常链与错误码 - raise - except Exception as exc: - # 兜底:mappers 调用过程中可能抛出 TypeError 等非契约异常,翻译为 DependencyError - # 防止穿透至核心层(INV-7);保留异常链以便追踪 - await self._logger.error( - "channel_session_find_sessions_by_filter_failed", - resource="channel_session", - error=str(exc), - ) - raise DependencyError("channel_session", Error(str(exc))) from exc + raise DependencyError("channel_session", Error(str(exc))) from exc async def touchChannelSessionLastMessageAt( self, @@ -1132,17 +1132,17 @@ class ChannelPersistenceAdapter( Raises: DependencyError: 数据库故障。 """ - commit = self._should_commit(tx) - try: - orm = await self._repos.session.get_by_session_id(session_id) - if orm is None: - return False - rowcount = await self._repos.session.update_last_message_at(orm.id, commit=commit) - return rowcount > 0 - except IntegrityError as exc: - raise self._translate_db_error(exc, "channel_session") from exc - except SQLAlchemyError as exc: - raise self._translate_db_error(exc, "channel_session") from exc + async with self._session_scope(tx) as (_, repos, commit): + try: + orm = await repos.session.get_by_session_id(session_id) + if orm is None: + return False + rowcount = await repos.session.update_last_message_at(orm.id, commit=commit) + return rowcount > 0 + except IntegrityError as exc: + raise self._translate_db_error(exc, "channel_session") from exc + except SQLAlchemyError as exc: + raise self._translate_db_error(exc, "channel_session") from exc async def touchSessionRouteInfo( self, @@ -1163,21 +1163,21 @@ class ChannelPersistenceAdapter( Raises: DependencyError: 数据库故障。 """ - commit = self._should_commit(tx) - try: - orm = await self._repos.session.get_by_session_id(session_id) - if orm is None: - return False - rowcount = await self._repos.session.update_route_info( - orm.id, - route_match_source=route_match_source, - commit=commit, - ) - return rowcount > 0 - except IntegrityError as exc: - raise self._translate_db_error(exc, "channel_session") from exc - except SQLAlchemyError as exc: - raise self._translate_db_error(exc, "channel_session") from exc + async with self._session_scope(tx) as (_, repos, commit): + try: + orm = await repos.session.get_by_session_id(session_id) + if orm is None: + return False + rowcount = await repos.session.update_route_info( + orm.id, + route_match_source=route_match_source, + commit=commit, + ) + return rowcount > 0 + except IntegrityError as exc: + raise self._translate_db_error(exc, "channel_session") from exc + except SQLAlchemyError as exc: + raise self._translate_db_error(exc, "channel_session") from exc async def listInactiveTemporarySessions( self, @@ -1193,44 +1193,45 @@ class ChannelPersistenceAdapter( Raises: DependencyError: 数据库故障。 """ - try: - stmt = ( - select(ChannelSessionORM) - .where( - ChannelSessionORM.is_temporary.is_(True), - ChannelSessionORM.is_deleted == 0, - ChannelSessionORM.closed_at.is_(None), - ChannelSessionORM.last_message_at < inactive_before, + async with self._session_scope(None) as (db, _, _): + try: + stmt = ( + select(ChannelSessionORM) + .where( + ChannelSessionORM.is_temporary.is_(True), + ChannelSessionORM.is_deleted == 0, + ChannelSessionORM.closed_at.is_(None), + ChannelSessionORM.last_message_at < inactive_before, + ) + .limit(limit) ) - .limit(limit) - ) - result = await self._db.execute(stmt) - orms = result.scalars().all() - # 批量查询关联账户 ORM,避免 N+1 查询 - account_ids = {orm.account_id for orm in orms} - account_orms_map: dict[int, ChannelAccountORM] = {} - if account_ids: - stmt = select(ChannelAccountORM).where(ChannelAccountORM.id.in_(account_ids)) - result = await self._db.execute(stmt) - account_orms_map = {a.id: a for a in result.scalars().all()} - return tuple(orm_to_channel_session(orm, account_orms_map.get(orm.account_id)) for orm in orms) - except IntegrityError as exc: - raise self._translate_db_error(exc, "channel_session") from exc - except SQLAlchemyError as exc: - raise self._translate_db_error(exc, "channel_session") from exc - except (NotFoundError, ConflictError, DependencyError): - # 契约层错误:mappers 翻译的 DependencyError 直接放行,不二次翻译, - # 保留原始异常链与错误码 - raise - except Exception as exc: - # 兜底:mappers 调用过程中可能抛出 TypeError 等非契约异常,翻译为 - # DependencyError 防止穿透至核心层(INV-7);保留异常链以便追踪 - await self._logger.error( - "channel_session_list_inactive_temporary_sessions_failed", - resource="channel_session", - error=str(exc), - ) - raise DependencyError("channel_session", Error(str(exc))) from exc + result = await db.execute(stmt) + orms = result.scalars().all() + # 批量查询关联账户 ORM,避免 N+1 查询 + account_ids = {orm.account_id for orm in orms} + account_orms_map: dict[int, ChannelAccountORM] = {} + if account_ids: + stmt = select(ChannelAccountORM).where(ChannelAccountORM.id.in_(account_ids)) + result = await db.execute(stmt) + account_orms_map = {a.id: a for a in result.scalars().all()} + return tuple(orm_to_channel_session(orm, account_orms_map.get(orm.account_id)) for orm in orms) + except IntegrityError as exc: + raise self._translate_db_error(exc, "channel_session") from exc + except SQLAlchemyError as exc: + raise self._translate_db_error(exc, "channel_session") from exc + except (NotFoundError, ConflictError, DependencyError): + # 契约层错误:mappers 翻译的 DependencyError 直接放行,不二次翻译, + # 保留原始异常链与错误码 + raise + except Exception as exc: + # 兜底:mappers 调用过程中可能抛出 TypeError 等非契约异常,翻译为 + # DependencyError 防止穿透至核心层(INV-7);保留异常链以便追踪 + await self._logger.error( + "channel_session_list_inactive_temporary_sessions_failed", + resource="channel_session", + error=str(exc), + ) + raise DependencyError("channel_session", Error(str(exc))) from exc async def cleanupInactiveSessions(self, session_ids: list[str]) -> int: """批量关闭非活跃临时会话(FR-27 inactive 临时会话清理)。 @@ -1248,37 +1249,39 @@ class ChannelPersistenceAdapter( """ if not session_ids: return 0 - try: - now = utc_now_naive() - stmt = ( - sa_update(ChannelSessionORM) - .where( - ChannelSessionORM.session_id.in_(session_ids), - ChannelSessionORM.is_deleted == 0, - ChannelSessionORM.closed_at.is_(None), + async with self._session_scope(None) as (db, _, commit): + try: + now = utc_now_naive() + stmt = ( + sa_update(ChannelSessionORM) + .where( + ChannelSessionORM.session_id.in_(session_ids), + ChannelSessionORM.is_deleted == 0, + ChannelSessionORM.closed_at.is_(None), + ) + .values( + closed_at=now, + updated_at=now, + version=ChannelSessionORM.version + 1, + ) ) - .values( - closed_at=now, - updated_at=now, - version=ChannelSessionORM.version + 1, + result = await db.execute(stmt) + if commit: + await db.commit() + return result.rowcount + except IntegrityError as exc: + raise self._translate_db_error(exc, "channel_session") from exc + except SQLAlchemyError as exc: + raise self._translate_db_error(exc, "channel_session") from exc + except (NotFoundError, ConflictError, DependencyError): + raise + except Exception as exc: + await self._logger.error( + "channel_session_cleanup_inactive_sessions_failed", + resource="channel_session", + error=str(exc), ) - ) - result = await self._db.execute(stmt) - await self._db.commit() - return result.rowcount - except IntegrityError as exc: - raise self._translate_db_error(exc, "channel_session") from exc - except SQLAlchemyError as exc: - raise self._translate_db_error(exc, "channel_session") from exc - except (NotFoundError, ConflictError, DependencyError): - raise - except Exception as exc: - await self._logger.error( - "channel_session_cleanup_inactive_sessions_failed", - resource="channel_session", - error=str(exc), - ) - raise DependencyError("channel_session", Error(str(exc))) from exc + raise DependencyError("channel_session", Error(str(exc))) from exc # ═══════════════════════════════════════════════════════════════════════════ # 配对审批 CRUD(fail-closed) @@ -1303,44 +1306,44 @@ class ChannelPersistenceAdapter( ConflictError: 唯一约束冲突。 DependencyError: 数据库故障(fail-closed)。 """ - commit = self._should_commit(tx) - try: - account_orm = await self._repos.account.get_by_type_and_account(cmd.channel_type, cmd.account_id) - if account_orm is None: - raise NotFoundError("channel_account", cmd.account_id) - now = utc_now_naive() - data = pairing_for_write( - { - "pairing_id": uuid.uuid4().hex, - "account_id": account_orm.id, - "peer_id": cmd.peer_id, - "peer_name": cmd.peer_name, - "status": PairingStatus.PENDING.value, - "expires_at": now + timedelta(seconds=cmd.expires_in_seconds), - "requested_at": now, - "created_by": cmd.created_by, - "updated_by": cmd.created_by, - } - ) - orm = await self._repos.pairing.create(data, commit=commit) - return orm_to_pairing(orm, account_orm) - except IntegrityError as exc: - raise self._translate_db_error(exc, "channel_pairing") from exc - except SQLAlchemyError as exc: - raise self._translate_db_error(exc, "channel_pairing") from exc - except (NotFoundError, ConflictError, DependencyError): - # 契约层错误:mappers 翻译的 DependencyError 与本适配器抛出的 NotFoundError/ConflictError - # 直接放行,不二次翻译,保留原始异常链与错误码 - raise - except Exception as exc: - # 兜底:mappers 调用过程中可能抛出 TypeError 等非契约异常,翻译为 DependencyError - # 防止穿透至核心层(INV-7);保留异常链以便追踪 - await self._logger.error( - "channel_pairing_create_failed", - resource="channel_pairing", - error=str(exc), - ) - raise DependencyError("channel_pairing", Error(str(exc))) from exc + async with self._session_scope(tx) as (_, repos, commit): + try: + account_orm = await repos.account.get_by_type_and_account(cmd.channel_type, cmd.account_id) + if account_orm is None: + raise NotFoundError("channel_account", cmd.account_id) + now = utc_now_naive() + data = pairing_for_write( + { + "pairing_id": uuid.uuid4().hex, + "account_id": account_orm.id, + "peer_id": cmd.peer_id, + "peer_name": cmd.peer_name, + "status": PairingStatus.PENDING.value, + "expires_at": now + timedelta(seconds=cmd.expires_in_seconds), + "requested_at": now, + "created_by": cmd.created_by, + "updated_by": cmd.created_by, + } + ) + orm = await repos.pairing.create(data, commit=commit) + return orm_to_pairing(orm, account_orm) + except IntegrityError as exc: + raise self._translate_db_error(exc, "channel_pairing") from exc + except SQLAlchemyError as exc: + raise self._translate_db_error(exc, "channel_pairing") from exc + except (NotFoundError, ConflictError, DependencyError): + # 契约层错误:mappers 翻译的 DependencyError 与本适配器抛出的 NotFoundError/ConflictError + # 直接放行,不二次翻译,保留原始异常链与错误码 + raise + except Exception as exc: + # 兜底:mappers 调用过程中可能抛出 TypeError 等非契约异常,翻译为 DependencyError + # 防止穿透至核心层(INV-7);保留异常链以便追踪 + await self._logger.error( + "channel_pairing_create_failed", + resource="channel_pairing", + error=str(exc), + ) + raise DependencyError("channel_pairing", Error(str(exc))) from exc async def getPairing(self, pairing_id: str) -> PairingRecord | None: """按 pairing_id 查询配对审批记录;不存在返回 None。 @@ -1352,29 +1355,30 @@ class ChannelPersistenceAdapter( Raises: DependencyError: 数据库故障(fail-closed)。 """ - try: - orm = await self._repos.pairing.get_by_pairing_id(pairing_id) - if orm is None: - return None - account_orm = await self._repos.account.get_by_id(orm.account_id) - return orm_to_pairing(orm, account_orm) - except IntegrityError as exc: - raise self._translate_db_error(exc, "channel_pairing") from exc - except SQLAlchemyError as exc: - raise self._translate_db_error(exc, "channel_pairing") from exc - except (NotFoundError, ConflictError, DependencyError): - # 契约层错误:mappers 翻译的 DependencyError 与本适配器抛出的 NotFoundError/ConflictError - # 直接放行,不二次翻译,保留原始异常链与错误码 - raise - except Exception as exc: - # 兜底:mappers 调用过程中可能抛出 TypeError 等非契约异常,翻译为 DependencyError - # 防止穿透至核心层(INV-7);保留异常链以便追踪 - await self._logger.error( - "channel_pairing_get_failed", - resource="channel_pairing", - error=str(exc), - ) - raise DependencyError("channel_pairing", Error(str(exc))) from exc + async with self._session_scope(None) as (_, repos, _): + try: + orm = await repos.pairing.get_by_pairing_id(pairing_id) + if orm is None: + return None + account_orm = await repos.account.get_by_id(orm.account_id) + return orm_to_pairing(orm, account_orm) + except IntegrityError as exc: + raise self._translate_db_error(exc, "channel_pairing") from exc + except SQLAlchemyError as exc: + raise self._translate_db_error(exc, "channel_pairing") from exc + except (NotFoundError, ConflictError, DependencyError): + # 契约层错误:mappers 翻译的 DependencyError 与本适配器抛出的 NotFoundError/ConflictError + # 直接放行,不二次翻译,保留原始异常链与错误码 + raise + except Exception as exc: + # 兜底:mappers 调用过程中可能抛出 TypeError 等非契约异常,翻译为 DependencyError + # 防止穿透至核心层(INV-7);保留异常链以便追踪 + await self._logger.error( + "channel_pairing_get_failed", + resource="channel_pairing", + error=str(exc), + ) + raise DependencyError("channel_pairing", Error(str(exc))) from exc async def updatePairingStatus( self, @@ -1410,50 +1414,50 @@ class ChannelPersistenceAdapter( ConflictError: 乐观锁版本不匹配或并发冲突。 DependencyError: 数据库故障(fail-closed)。 """ - commit = self._should_commit(tx) - try: - orm = await self._repos.pairing.get_by_pairing_id(pairing_id, for_update=True) - if orm is None: - raise NotFoundError("pairing", pairing_id) - if orm.version != expected_version: - raise ConflictError("pairing") - now = utc_now_naive() - orm.status = status.value - if status in (PairingStatus.APPROVED, PairingStatus.REJECTED) and approver_id is not None: - orm.approver_id = approver_id - if reason is not None: - orm.reason = reason - if updated_by is not None: - orm.updated_by = updated_by - if status == PairingStatus.APPROVED: - orm.approved_at = now - elif status == PairingStatus.REJECTED: - orm.rejected_at = now - elif status == PairingStatus.REVOKED: - orm.revoked_at = now - elif status == PairingStatus.EXPIRED: - orm.expired_at = now - orm.version += 1 - updated = await self._repos.pairing.update(orm, {}, commit=commit) - account_orm = await self._repos.account.get_by_id(updated.account_id) - return orm_to_pairing(updated, account_orm) - except IntegrityError as exc: - raise self._translate_db_error(exc, "channel_pairing") from exc - except SQLAlchemyError as exc: - raise self._translate_db_error(exc, "channel_pairing") from exc - except (NotFoundError, ConflictError, DependencyError): - # 契约层错误:mappers 翻译的 DependencyError 与本适配器抛出的 NotFoundError/ConflictError - # 直接放行,不二次翻译,保留原始异常链与错误码 - raise - except Exception as exc: - # 兜底:mappers 调用过程中可能抛出 TypeError 等非契约异常,翻译为 DependencyError - # 防止穿透至核心层(INV-7);保留异常链以便追踪 - await self._logger.error( - "channel_pairing_update_status_failed", - resource="channel_pairing", - error=str(exc), - ) - raise DependencyError("channel_pairing", Error(str(exc))) from exc + async with self._session_scope(tx) as (_, repos, commit): + try: + orm = await repos.pairing.get_by_pairing_id(pairing_id, for_update=True) + if orm is None: + raise NotFoundError("pairing", pairing_id) + if orm.version != expected_version: + raise ConflictError("pairing") + now = utc_now_naive() + orm.status = status.value + if status in (PairingStatus.APPROVED, PairingStatus.REJECTED) and approver_id is not None: + orm.approver_id = approver_id + if reason is not None: + orm.reason = reason + if updated_by is not None: + orm.updated_by = updated_by + if status == PairingStatus.APPROVED: + orm.approved_at = now + elif status == PairingStatus.REJECTED: + orm.rejected_at = now + elif status == PairingStatus.REVOKED: + orm.revoked_at = now + elif status == PairingStatus.EXPIRED: + orm.expired_at = now + orm.version += 1 + updated = await repos.pairing.update(orm, {}, commit=commit) + account_orm = await repos.account.get_by_id(updated.account_id) + return orm_to_pairing(updated, account_orm) + except IntegrityError as exc: + raise self._translate_db_error(exc, "channel_pairing") from exc + except SQLAlchemyError as exc: + raise self._translate_db_error(exc, "channel_pairing") from exc + except (NotFoundError, ConflictError, DependencyError): + # 契约层错误:mappers 翻译的 DependencyError 与本适配器抛出的 NotFoundError/ConflictError + # 直接放行,不二次翻译,保留原始异常链与错误码 + raise + except Exception as exc: + # 兜底:mappers 调用过程中可能抛出 TypeError 等非契约异常,翻译为 DependencyError + # 防止穿透至核心层(INV-7);保留异常链以便追踪 + await self._logger.error( + "channel_pairing_update_status_failed", + resource="channel_pairing", + error=str(exc), + ) + raise DependencyError("channel_pairing", Error(str(exc))) from exc async def getActivePairing( self, @@ -1469,31 +1473,32 @@ class ChannelPersistenceAdapter( Raises: DependencyError: 数据库故障(fail-closed)。 """ - try: - account_orm = await self._repos.account.get_by_type_and_account(channel_type, account_id) - if account_orm is None: - return None - orm = await self._repos.pairing.get_active_by_account_and_peer(account_orm.id, peer_id) - if orm is None: - return None - return orm_to_pairing(orm, account_orm) - except IntegrityError as exc: - raise self._translate_db_error(exc, "channel_pairing") from exc - except SQLAlchemyError as exc: - raise self._translate_db_error(exc, "channel_pairing") from exc - except (NotFoundError, ConflictError, DependencyError): - # 契约层错误:mappers 翻译的 DependencyError 与本适配器抛出的 NotFoundError/ConflictError - # 直接放行,不二次翻译,保留原始异常链与错误码 - raise - except Exception as exc: - # 兜底:mappers 调用过程中可能抛出 TypeError 等非契约异常,翻译为 DependencyError - # 防止穿透至核心层(INV-7);保留异常链以便追踪 - await self._logger.error( - "channel_pairing_get_active_failed", - resource="channel_pairing", - error=str(exc), - ) - raise DependencyError("channel_pairing", Error(str(exc))) from exc + async with self._session_scope(None) as (_, repos, _): + try: + account_orm = await repos.account.get_by_type_and_account(channel_type, account_id) + if account_orm is None: + return None + orm = await repos.pairing.get_active_by_account_and_peer(account_orm.id, peer_id) + if orm is None: + return None + return orm_to_pairing(orm, account_orm) + except IntegrityError as exc: + raise self._translate_db_error(exc, "channel_pairing") from exc + except SQLAlchemyError as exc: + raise self._translate_db_error(exc, "channel_pairing") from exc + except (NotFoundError, ConflictError, DependencyError): + # 契约层错误:mappers 翻译的 DependencyError 与本适配器抛出的 NotFoundError/ConflictError + # 直接放行,不二次翻译,保留原始异常链与错误码 + raise + except Exception as exc: + # 兜底:mappers 调用过程中可能抛出 TypeError 等非契约异常,翻译为 DependencyError + # 防止穿透至核心层(INV-7);保留异常链以便追踪 + await self._logger.error( + "channel_pairing_get_active_failed", + resource="channel_pairing", + error=str(exc), + ) + raise DependencyError("channel_pairing", Error(str(exc))) from exc async def listExpiredPendingPairings( self, @@ -1507,26 +1512,27 @@ class ChannelPersistenceAdapter( Raises: DependencyError: 数据库故障(fail-closed)。 """ - try: - orms = await self._repos.pairing.list_expired_pending(before, limit=limit) - return tuple(orm_to_pairing(orm) for orm in orms) - except IntegrityError as exc: - raise self._translate_db_error(exc, "channel_pairing") from exc - except SQLAlchemyError as exc: - raise self._translate_db_error(exc, "channel_pairing") from exc - except (NotFoundError, ConflictError, DependencyError): - # 契约层错误:mappers 翻译的 DependencyError 与本适配器抛出的 NotFoundError/ConflictError - # 直接放行,不二次翻译,保留原始异常链与错误码 - raise - except Exception as exc: - # 兜底:mappers 调用过程中可能抛出 TypeError 等非契约异常,翻译为 DependencyError - # 防止穿透至核心层(INV-7);保留异常链以便追踪 - await self._logger.error( - "channel_pairing_list_expired_pending_failed", - resource="channel_pairing", - error=str(exc), - ) - raise DependencyError("channel_pairing", Error(str(exc))) from exc + async with self._session_scope(None) as (_, repos, _): + try: + orms = await repos.pairing.list_expired_pending(before, limit=limit) + return tuple(orm_to_pairing(orm) for orm in orms) + except IntegrityError as exc: + raise self._translate_db_error(exc, "channel_pairing") from exc + except SQLAlchemyError as exc: + raise self._translate_db_error(exc, "channel_pairing") from exc + except (NotFoundError, ConflictError, DependencyError): + # 契约层错误:mappers 翻译的 DependencyError 与本适配器抛出的 NotFoundError/ConflictError + # 直接放行,不二次翻译,保留原始异常链与错误码 + raise + except Exception as exc: + # 兜底:mappers 调用过程中可能抛出 TypeError 等非契约异常,翻译为 DependencyError + # 防止穿透至核心层(INV-7);保留异常链以便追踪 + await self._logger.error( + "channel_pairing_list_expired_pending_failed", + resource="channel_pairing", + error=str(exc), + ) + raise DependencyError("channel_pairing", Error(str(exc))) from exc async def countPairings(self, query: PairingQuery) -> int: """按条件统计配对审批记录数(``pairing/count`` 操作)。 @@ -1534,34 +1540,37 @@ class ChannelPersistenceAdapter( 与 ``listPairings`` 过滤语义一致,仅返回计数,供 Dashboard 待办 角标等轻量场景使用。 """ - try: - account_pks = await self._resolve_pairing_account_pks(query) - if account_pks is not None and not account_pks: - return 0 - return await self._repos.pairing.count( - account_id=account_pks[0] if account_pks and len(account_pks) == 1 else None, - account_ids=account_pks if account_pks and len(account_pks) > 1 else None, - peer_id=query.peer_id, - status=query.status.value if query.status is not None else None, - start_time=_to_naive_utc(query.created_after), - end_time=_to_naive_utc(query.created_before), - ) - except IntegrityError as exc: - raise self._translate_db_error(exc, "channel_pairing") from exc - except SQLAlchemyError as exc: - raise self._translate_db_error(exc, "channel_pairing") from exc - except (NotFoundError, ConflictError, DependencyError): - raise - except Exception as exc: - await self._logger.error( - "channel_pairing_count_failed", - resource="channel_pairing", - error=str(exc), - ) - raise DependencyError("channel_pairing", Error(str(exc))) from exc + async with self._session_scope(None) as (db, repos, _): + try: + account_pks = await self._resolve_pairing_account_pks(db, repos, query) + if account_pks is not None and not account_pks: + return 0 + return await repos.pairing.count( + account_id=account_pks[0] if account_pks and len(account_pks) == 1 else None, + account_ids=account_pks if account_pks and len(account_pks) > 1 else None, + peer_id=query.peer_id, + status=query.status.value if query.status is not None else None, + start_time=_to_naive_utc(query.created_after), + end_time=_to_naive_utc(query.created_before), + ) + except IntegrityError as exc: + raise self._translate_db_error(exc, "channel_pairing") from exc + except SQLAlchemyError as exc: + raise self._translate_db_error(exc, "channel_pairing") from exc + except (NotFoundError, ConflictError, DependencyError): + raise + except Exception as exc: + await self._logger.error( + "channel_pairing_count_failed", + resource="channel_pairing", + error=str(exc), + ) + raise DependencyError("channel_pairing", Error(str(exc))) from exc async def _resolve_pairing_account_pks( self, + db: AsyncSession, + repos: Repositories, query: PairingQuery, ) -> list[int] | None: """解析配对查询中的账户主键列表。 @@ -1570,7 +1579,7 @@ class ChannelPersistenceAdapter( 账户,调用方可直接返回 0 / 空元组。 """ if query.channel_type is not None and query.account_id is not None: - account_orm = await self._repos.account.get_by_type_and_account(query.channel_type, query.account_id) + account_orm = await repos.account.get_by_type_and_account(query.channel_type, query.account_id) if account_orm is None: return [] return [account_orm.id] @@ -1579,14 +1588,14 @@ class ChannelPersistenceAdapter( ChannelAccountORM.account_id == query.account_id, ChannelAccountORM.is_deleted == 0, ) - result = await self._db.execute(stmt) + result = await db.execute(stmt) return list(result.scalars().all()) if query.channel_type is not None: stmt = select(ChannelAccountORM.id).where( ChannelAccountORM.channel_type == query.channel_type, ChannelAccountORM.is_deleted == 0, ) - result = await self._db.execute(stmt) + result = await db.execute(stmt) return list(result.scalars().all()) return None @@ -1605,48 +1614,49 @@ class ChannelPersistenceAdapter( Raises: DependencyError: 数据库故障(fail-closed)。 """ - try: - account_pks = await self._resolve_pairing_account_pks(query) - if account_pks is not None and not account_pks: - return () + async with self._session_scope(None) as (db, repos, _): + try: + account_pks = await self._resolve_pairing_account_pks(db, repos, query) + if account_pks is not None and not account_pks: + return () - orms = await self._repos.pairing.list( - account_id=account_pks[0] if account_pks and len(account_pks) == 1 else None, - account_ids=account_pks if account_pks and len(account_pks) > 1 else None, - peer_id=query.peer_id, - status=query.status.value if query.status is not None else None, - start_time=_to_naive_utc(query.created_after), - end_time=_to_naive_utc(query.created_before), - limit=query.limit, - offset=query.offset, - ) - if not orms: - return () - # 批量查询关联账户 ORM,避免 N+1 查询 - account_ids = {orm.account_id for orm in orms} - account_orms_map: dict[int, ChannelAccountORM] = {} - if account_ids: - stmt = select(ChannelAccountORM).where(ChannelAccountORM.id.in_(account_ids)) - result = await self._db.execute(stmt) - account_orms_map = {a.id: a for a in result.scalars().all()} - return tuple(orm_to_pairing(orm, account_orms_map.get(orm.account_id)) for orm in orms) - except IntegrityError as exc: - raise self._translate_db_error(exc, "channel_pairing") from exc - except SQLAlchemyError as exc: - raise self._translate_db_error(exc, "channel_pairing") from exc - except (NotFoundError, ConflictError, DependencyError): - # 契约层错误:mappers 翻译的 DependencyError 与本适配器抛出的 NotFoundError/ConflictError - # 直接放行,不二次翻译,保留原始异常链与错误码 - raise - except Exception as exc: - # 兜底:mappers 调用过程中可能抛出 TypeError 等非契约异常,翻译为 DependencyError - # 防止穿透至核心层(INV-7);保留异常链以便追踪 - await self._logger.error( - "channel_pairing_list_failed", - resource="channel_pairing", - error=str(exc), - ) - raise DependencyError("channel_pairing", Error(str(exc))) from exc + orms = await repos.pairing.list( + account_id=account_pks[0] if account_pks and len(account_pks) == 1 else None, + account_ids=account_pks if account_pks and len(account_pks) > 1 else None, + peer_id=query.peer_id, + status=query.status.value if query.status is not None else None, + start_time=_to_naive_utc(query.created_after), + end_time=_to_naive_utc(query.created_before), + limit=query.limit, + offset=query.offset, + ) + if not orms: + return () + # 批量查询关联账户 ORM,避免 N+1 查询 + account_ids = {orm.account_id for orm in orms} + account_orms_map: dict[int, ChannelAccountORM] = {} + if account_ids: + stmt = select(ChannelAccountORM).where(ChannelAccountORM.id.in_(account_ids)) + result = await db.execute(stmt) + account_orms_map = {a.id: a for a in result.scalars().all()} + return tuple(orm_to_pairing(orm, account_orms_map.get(orm.account_id)) for orm in orms) + except IntegrityError as exc: + raise self._translate_db_error(exc, "channel_pairing") from exc + except SQLAlchemyError as exc: + raise self._translate_db_error(exc, "channel_pairing") from exc + except (NotFoundError, ConflictError, DependencyError): + # 契约层错误:mappers 翻译的 DependencyError 与本适配器抛出的 NotFoundError/ConflictError + # 直接放行,不二次翻译,保留原始异常链与错误码 + raise + except Exception as exc: + # 兜底:mappers 调用过程中可能抛出 TypeError 等非契约异常,翻译为 DependencyError + # 防止穿透至核心层(INV-7);保留异常链以便追踪 + await self._logger.error( + "channel_pairing_list_failed", + resource="channel_pairing", + error=str(exc), + ) + raise DependencyError("channel_pairing", Error(str(exc))) from exc async def getPairingStats( self, @@ -1674,123 +1684,124 @@ class ChannelPersistenceAdapter( Raises: DependencyError: 数据库故障。 """ - try: - base_filters: list[Any] = [ChannelPairingORM.is_deleted == 0] - # DB 时间列为 naive UTC,剥离 DTO 入参的 tzinfo(见 _to_naive_utc 说明)。 - start_naive = _to_naive_utc(query.start_time) - end_naive = _to_naive_utc(query.end_time) - if start_naive is not None: - base_filters.append(ChannelPairingORM.requested_at >= start_naive) - if end_naive is not None: - base_filters.append(ChannelPairingORM.requested_at <= end_naive) + async with self._session_scope(None) as (db, _, _): + try: + base_filters: list[Any] = [ChannelPairingORM.is_deleted == 0] + # DB 时间列为 naive UTC,剥离 DTO 入参的 tzinfo(见 _to_naive_utc 说明)。 + start_naive = _to_naive_utc(query.start_time) + end_naive = _to_naive_utc(query.end_time) + if start_naive is not None: + base_filters.append(ChannelPairingORM.requested_at >= start_naive) + if end_naive is not None: + base_filters.append(ChannelPairingORM.requested_at <= end_naive) - # 全局聚合:总数 + 各状态计数 + 平均审批时长 - agg_stmt = select( - func.count(ChannelPairingORM.id).label("total_requested"), - func.sum( - case( - (ChannelPairingORM.status == "approved", 1), - else_=0, - ) - ).label("approved_count"), - func.sum( - case( - (ChannelPairingORM.status == "rejected", 1), - else_=0, - ) - ).label("rejected_count"), - func.sum( - case( - (ChannelPairingORM.status == "revoked", 1), - else_=0, - ) - ).label("revoked_count"), - func.sum( - case( - (ChannelPairingORM.status == "expired", 1), - else_=0, - ) - ).label("expired_count"), - func.avg( - extract( - "epoch", - ChannelPairingORM.approved_at - ChannelPairingORM.requested_at, - ) - ).label("avg_approval_seconds"), - ).where(*base_filters) - if query.channel_type is not None: - agg_stmt = agg_stmt.join( - ChannelAccountORM, - ChannelPairingORM.account_id == ChannelAccountORM.id, - ).where(ChannelAccountORM.channel_type == query.channel_type) - agg_row = (await self._db.execute(agg_stmt)).one() - total_requested = int(agg_row.total_requested or 0) - approved_count = int(agg_row.approved_count or 0) - rejected_count = int(agg_row.rejected_count or 0) - revoked_count = int(agg_row.revoked_count or 0) - expired_count = int(agg_row.expired_count or 0) - approve_rate = (approved_count / total_requested * 100) if total_requested > 0 else 0.0 - avg_approval_seconds = ( - float(agg_row.avg_approval_seconds) if agg_row.avg_approval_seconds is not None else 0.0 - ) - - # 时间序列:按 granularity 分桶,每桶含申请数 / 批准数双计数 - bucket_expr = func.date_trunc(query.granularity, ChannelPairingORM.requested_at) - trend_stmt = ( - select( - bucket_expr.label("bucket"), - func.count(ChannelPairingORM.id).label("requested"), + # 全局聚合:总数 + 各状态计数 + 平均审批时长 + agg_stmt = select( + func.count(ChannelPairingORM.id).label("total_requested"), func.sum( case( (ChannelPairingORM.status == "approved", 1), else_=0, ) - ).label("approved"), + ).label("approved_count"), + func.sum( + case( + (ChannelPairingORM.status == "rejected", 1), + else_=0, + ) + ).label("rejected_count"), + func.sum( + case( + (ChannelPairingORM.status == "revoked", 1), + else_=0, + ) + ).label("revoked_count"), + func.sum( + case( + (ChannelPairingORM.status == "expired", 1), + else_=0, + ) + ).label("expired_count"), + func.avg( + extract( + "epoch", + ChannelPairingORM.approved_at - ChannelPairingORM.requested_at, + ) + ).label("avg_approval_seconds"), + ).where(*base_filters) + if query.channel_type is not None: + agg_stmt = agg_stmt.join( + ChannelAccountORM, + ChannelPairingORM.account_id == ChannelAccountORM.id, + ).where(ChannelAccountORM.channel_type == query.channel_type) + agg_row = (await db.execute(agg_stmt)).one() + total_requested = int(agg_row.total_requested or 0) + approved_count = int(agg_row.approved_count or 0) + rejected_count = int(agg_row.rejected_count or 0) + revoked_count = int(agg_row.revoked_count or 0) + expired_count = int(agg_row.expired_count or 0) + approve_rate = (approved_count / total_requested * 100) if total_requested > 0 else 0.0 + avg_approval_seconds = ( + float(agg_row.avg_approval_seconds) if agg_row.avg_approval_seconds is not None else 0.0 ) - .where(*base_filters) - .group_by(bucket_expr) - .order_by(bucket_expr) - ) - if query.channel_type is not None: - trend_stmt = trend_stmt.join( - ChannelAccountORM, - ChannelPairingORM.account_id == ChannelAccountORM.id, - ).where(ChannelAccountORM.channel_type == query.channel_type) - trend_result = await self._db.execute(trend_stmt) - trend: list[PairingTrendPoint] = [ - PairingTrendPoint( - timestamp=bucket, - requested=int(req_val or 0), - approved=int(appr_val or 0), - ) - for bucket, req_val, appr_val in trend_result.all() - ] - return PairingStatsResult( - total_requested=total_requested, - approved_count=approved_count, - rejected_count=rejected_count, - revoked_count=revoked_count, - expired_count=expired_count, - approve_rate=approve_rate, - avg_approval_seconds=avg_approval_seconds, - trend=tuple(trend), - ) - except IntegrityError as exc: - raise self._translate_db_error(exc, "channel_pairing") from exc - except SQLAlchemyError as exc: - raise self._translate_db_error(exc, "channel_pairing") from exc - except (NotFoundError, ConflictError, DependencyError): - # 契约层错误:直接放行,不二次翻译,保留原始异常链与错误码 - raise - except Exception as exc: - # 兜底:防止非契约异常穿透至核心层(INV-7);保留异常链以便追踪 - await self._logger.error( - "channel_pairing_get_stats_failed", - resource="channel_pairing", - error=str(exc), - ) - raise DependencyError("channel_pairing", Error(str(exc))) from exc + # 时间序列:按 granularity 分桶,每桶含申请数 / 批准数双计数 + bucket_expr = func.date_trunc(query.granularity, ChannelPairingORM.requested_at) + trend_stmt = ( + select( + bucket_expr.label("bucket"), + func.count(ChannelPairingORM.id).label("requested"), + func.sum( + case( + (ChannelPairingORM.status == "approved", 1), + else_=0, + ) + ).label("approved"), + ) + .where(*base_filters) + .group_by(bucket_expr) + .order_by(bucket_expr) + ) + if query.channel_type is not None: + trend_stmt = trend_stmt.join( + ChannelAccountORM, + ChannelPairingORM.account_id == ChannelAccountORM.id, + ).where(ChannelAccountORM.channel_type == query.channel_type) + trend_result = await db.execute(trend_stmt) + trend: list[PairingTrendPoint] = [ + PairingTrendPoint( + timestamp=bucket, + requested=int(req_val or 0), + approved=int(appr_val or 0), + ) + for bucket, req_val, appr_val in trend_result.all() + ] + + return PairingStatsResult( + total_requested=total_requested, + approved_count=approved_count, + rejected_count=rejected_count, + revoked_count=revoked_count, + expired_count=expired_count, + approve_rate=approve_rate, + avg_approval_seconds=avg_approval_seconds, + trend=tuple(trend), + ) + except IntegrityError as exc: + raise self._translate_db_error(exc, "channel_pairing") from exc + except SQLAlchemyError as exc: + raise self._translate_db_error(exc, "channel_pairing") from exc + except (NotFoundError, ConflictError, DependencyError): + # 契约层错误:直接放行,不二次翻译,保留原始异常链与错误码 + raise + except Exception as exc: + # 兜底:防止非契约异常穿透至核心层(INV-7);保留异常链以便追踪 + await self._logger.error( + "channel_pairing_get_stats_failed", + resource="channel_pairing", + error=str(exc), + ) + raise DependencyError("channel_pairing", Error(str(exc))) from exc async def cleanupOldTerminalPairings(self, before: datetime, limit: int) -> int: """物理删除早于指定时间的终态配对记录(FR-33 终态清理)。 @@ -1804,37 +1815,39 @@ class ChannelPersistenceAdapter( Raises: DependencyError: 数据库故障(fail-closed)。 """ - try: - # DB 时间列为 naive UTC,剥离入参的 tzinfo(见 _to_naive_utc 说明)。 - before_naive = _to_naive_utc(before) - subq = ( - select(ChannelPairingORM.id) - .where( - ChannelPairingORM.status.in_(["expired", "rejected", "revoked"]), - ChannelPairingORM.updated_at < before_naive, - ChannelPairingORM.is_deleted == 0, + async with self._session_scope(None) as (db, _, commit): + try: + # DB 时间列为 naive UTC,剥离入参的 tzinfo(见 _to_naive_utc 说明)。 + before_naive = _to_naive_utc(before) + subq = ( + select(ChannelPairingORM.id) + .where( + ChannelPairingORM.status.in_(["expired", "rejected", "revoked"]), + ChannelPairingORM.updated_at < before_naive, + ChannelPairingORM.is_deleted == 0, + ) + .limit(limit) ) - .limit(limit) - ) - stmt = delete(ChannelPairingORM).where(ChannelPairingORM.id.in_(subq)) - result = await self._db.execute(stmt) - await self._db.commit() - return int(result.rowcount or 0) - except IntegrityError as exc: - raise self._translate_db_error(exc, "channel_pairing") from exc - except SQLAlchemyError as exc: - raise self._translate_db_error(exc, "channel_pairing") from exc - except (NotFoundError, ConflictError, DependencyError): - # 契约层错误:直接放行,不二次翻译,保留原始异常链与错误码 - raise - except Exception as exc: - # 兜底:防止非契约异常穿透至核心层(INV-7);保留异常链以便追踪 - await self._logger.error( - "channel_pairing_cleanup_old_terminal_failed", - resource="channel_pairing", - error=str(exc), - ) - raise DependencyError("channel_pairing", Error(str(exc))) from exc + stmt = delete(ChannelPairingORM).where(ChannelPairingORM.id.in_(subq)) + result = await db.execute(stmt) + if commit: + await db.commit() + return int(result.rowcount or 0) + except IntegrityError as exc: + raise self._translate_db_error(exc, "channel_pairing") from exc + except SQLAlchemyError as exc: + raise self._translate_db_error(exc, "channel_pairing") from exc + except (NotFoundError, ConflictError, DependencyError): + # 契约层错误:直接放行,不二次翻译,保留原始异常链与错误码 + raise + except Exception as exc: + # 兜底:防止非契约异常穿透至核心层(INV-7);保留异常链以便追踪 + await self._logger.error( + "channel_pairing_cleanup_old_terminal_failed", + resource="channel_pairing", + error=str(exc), + ) + raise DependencyError("channel_pairing", Error(str(exc))) from exc # ═══════════════════════════════════════════════════════════════════════════ # 审计日志(fail-closed) @@ -1864,7 +1877,6 @@ class ChannelPersistenceAdapter( Raises: DependencyError: 数据库故障(fail-closed)。 """ - commit = self._should_commit(tx) data = audit_log_for_write( { "audit_log_id": uuid.uuid4().hex, @@ -1883,26 +1895,27 @@ class ChannelPersistenceAdapter( "timestamp": utc_now_naive(), } ) - try: - orm = await self._repos.audit_log.create(data, commit=commit) - return AuditLogId(orm.audit_log_id) - except IntegrityError as exc: - raise self._translate_db_error(exc, "channel_audit_log") from exc - except SQLAlchemyError as exc: - raise self._translate_db_error(exc, "channel_audit_log") from exc - except (NotFoundError, ConflictError, DependencyError): - # 契约层错误:mappers 翻译的 DependencyError 与本适配器抛出的 NotFoundError/ConflictError - # 直接放行,不二次翻译,保留原始异常链与错误码 - raise - except Exception as exc: - # 兜底:mappers 调用过程中可能抛出 TypeError 等非契约异常,翻译为 DependencyError - # 防止穿透至核心层(INV-7);保留异常链以便追踪 - await self._logger.error( - "channel_audit_log_save_failed", - resource="channel_audit_log", - error=str(exc), - ) - raise DependencyError("channel_audit_log", Error(str(exc))) from exc + async with self._session_scope(tx) as (_, repos, commit): + try: + orm = await repos.audit_log.create(data, commit=commit) + return AuditLogId(orm.audit_log_id) + except IntegrityError as exc: + raise self._translate_db_error(exc, "channel_audit_log") from exc + except SQLAlchemyError as exc: + raise self._translate_db_error(exc, "channel_audit_log") from exc + except (NotFoundError, ConflictError, DependencyError): + # 契约层错误:mappers 翻译的 DependencyError 与本适配器抛出的 NotFoundError/ConflictError + # 直接放行,不二次翻译,保留原始异常链与错误码 + raise + except Exception as exc: + # 兜底:mappers 调用过程中可能抛出 TypeError 等非契约异常,翻译为 DependencyError + # 防止穿透至核心层(INV-7);保留异常链以便追踪 + await self._logger.error( + "channel_audit_log_save_failed", + resource="channel_audit_log", + error=str(exc), + ) + raise DependencyError("channel_audit_log", Error(str(exc))) from exc async def queryAuditLogs(self, query: AuditQuery) -> tuple[AuditEntry, ...]: """按条件查询审计日志,按时间倒序。 @@ -1914,44 +1927,45 @@ class ChannelPersistenceAdapter( TimeoutError: 查询超时(HTTP 504)。 DependencyError: 数据库故障。 """ - try: - orms = await asyncio.wait_for( - self._repos.audit_log.list( - operation=query.operation_type.value if query.operation_type is not None else None, - operator=query.operator, - target_channel=query.target_channel if query.target_channel is not None else None, - target_account=query.target_account, - start_time=_to_naive_utc(query.start_time), - end_time=_to_naive_utc(query.end_time), - trace_id=query.trace_id, - limit=query.limit, - offset=query.offset, - ), - timeout=5.0, - ) - return tuple(orm_to_audit_log(orm) for orm in orms) - except TimeoutError as exc: - raise OperationTimeoutError( - timeout_ms=5000, - message="审计日志查询超时", - ) from exc - except IntegrityError as exc: - raise self._translate_db_error(exc, "channel_audit_log") from exc - except SQLAlchemyError as exc: - raise self._translate_db_error(exc, "channel_audit_log") from exc - except (NotFoundError, ConflictError, DependencyError): - # 契约层错误:mappers 翻译的 DependencyError 与本适配器抛出的 NotFoundError/ConflictError - # 直接放行,不二次翻译,保留原始异常链与错误码 - raise - except Exception as exc: - # 兜底:mappers 调用过程中可能抛出 TypeError 等非契约异常,翻译为 DependencyError - # 防止穿透至核心层(INV-7);保留异常链以便追踪 - await self._logger.error( - "channel_audit_log_query_failed", - resource="channel_audit_log", - error=str(exc), - ) - raise DependencyError("channel_audit_log", Error(str(exc))) from exc + async with self._session_scope(None) as (_, repos, _): + try: + orms = await asyncio.wait_for( + repos.audit_log.list( + operation=query.operation_type.value if query.operation_type is not None else None, + operator=query.operator, + target_channel=query.target_channel if query.target_channel is not None else None, + target_account=query.target_account, + start_time=_to_naive_utc(query.start_time), + end_time=_to_naive_utc(query.end_time), + trace_id=query.trace_id, + limit=query.limit, + offset=query.offset, + ), + timeout=5.0, + ) + return tuple(orm_to_audit_log(orm) for orm in orms) + except TimeoutError as exc: + raise OperationTimeoutError( + timeout_ms=5000, + message="审计日志查询超时", + ) from exc + except IntegrityError as exc: + raise self._translate_db_error(exc, "channel_audit_log") from exc + except SQLAlchemyError as exc: + raise self._translate_db_error(exc, "channel_audit_log") from exc + except (NotFoundError, ConflictError, DependencyError): + # 契约层错误:mappers 翻译的 DependencyError 与本适配器抛出的 NotFoundError/ConflictError + # 直接放行,不二次翻译,保留原始异常链与错误码 + raise + except Exception as exc: + # 兜底:mappers 调用过程中可能抛出 TypeError 等非契约异常,翻译为 DependencyError + # 防止穿透至核心层(INV-7);保留异常链以便追踪 + await self._logger.error( + "channel_audit_log_query_failed", + resource="channel_audit_log", + error=str(exc), + ) + raise DependencyError("channel_audit_log", Error(str(exc))) from exc async def countAuditLogs(self, query: AuditQuery) -> int: """按条件统计审计日志数量,不计 ``limit`` / ``offset`` 分页。 @@ -1963,41 +1977,42 @@ class ChannelPersistenceAdapter( TimeoutError: 查询超时(HTTP 504)。 DependencyError: 数据库故障。 """ - try: - return await asyncio.wait_for( - self._repos.audit_log.count( - operation=query.operation_type.value if query.operation_type is not None else None, - operator=query.operator, - target_channel=query.target_channel if query.target_channel is not None else None, - target_account=query.target_account, - start_time=_to_naive_utc(query.start_time), - end_time=_to_naive_utc(query.end_time), - trace_id=query.trace_id, - ), - timeout=5.0, - ) - except TimeoutError as exc: - raise OperationTimeoutError( - timeout_ms=5000, - message="审计日志计数超时", - ) from exc - except IntegrityError as exc: - raise self._translate_db_error(exc, "channel_audit_log") from exc - except SQLAlchemyError as exc: - raise self._translate_db_error(exc, "channel_audit_log") from exc - except (NotFoundError, ConflictError, DependencyError): - # 契约层错误:mappers 翻译的 DependencyError 与本适配器抛出的 NotFoundError/ConflictError - # 直接放行,不二次翻译,保留原始异常链与错误码 - raise - except Exception as exc: - # 兜底:mappers 调用过程中可能抛出 TypeError 等非契约异常,翻译为 DependencyError - # 防止穿透至核心层(INV-7);保留异常链以便追踪 - await self._logger.error( - "channel_audit_log_count_failed", - resource="channel_audit_log", - error=str(exc), - ) - raise DependencyError("channel_audit_log", Error(str(exc))) from exc + async with self._session_scope(None) as (_, repos, _): + try: + return await asyncio.wait_for( + repos.audit_log.count( + operation=query.operation_type.value if query.operation_type is not None else None, + operator=query.operator, + target_channel=query.target_channel if query.target_channel is not None else None, + target_account=query.target_account, + start_time=_to_naive_utc(query.start_time), + end_time=_to_naive_utc(query.end_time), + trace_id=query.trace_id, + ), + timeout=5.0, + ) + except TimeoutError as exc: + raise OperationTimeoutError( + timeout_ms=5000, + message="审计日志计数超时", + ) from exc + except IntegrityError as exc: + raise self._translate_db_error(exc, "channel_audit_log") from exc + except SQLAlchemyError as exc: + raise self._translate_db_error(exc, "channel_audit_log") from exc + except (NotFoundError, ConflictError, DependencyError): + # 契约层错误:mappers 翻译的 DependencyError 与本适配器抛出的 NotFoundError/ConflictError + # 直接放行,不二次翻译,保留原始异常链与错误码 + raise + except Exception as exc: + # 兜底:mappers 调用过程中可能抛出 TypeError 等非契约异常,翻译为 DependencyError + # 防止穿透至核心层(INV-7);保留异常链以便追踪 + await self._logger.error( + "channel_audit_log_count_failed", + resource="channel_audit_log", + error=str(exc), + ) + raise DependencyError("channel_audit_log", Error(str(exc))) from exc async def deleteOldAuditLogs( self, @@ -2016,16 +2031,17 @@ class ChannelPersistenceAdapter( Raises: DependencyError: 数据库故障。 """ - try: - return await self._repos.audit_log.delete_old_logs( - before, - limit=limit, - commit=True, - ) - except IntegrityError as exc: - raise self._translate_db_error(exc, "channel_audit_log") from exc - except SQLAlchemyError as exc: - raise self._translate_db_error(exc, "channel_audit_log") from exc + async with self._session_scope(None) as (_, repos, commit): + try: + return await repos.audit_log.delete_old_logs( + before, + limit=limit, + commit=commit, + ) + except IntegrityError as exc: + raise self._translate_db_error(exc, "channel_audit_log") from exc + except SQLAlchemyError as exc: + raise self._translate_db_error(exc, "channel_audit_log") from exc async def getAuditLog( self, @@ -2042,36 +2058,37 @@ class ChannelPersistenceAdapter( TimeoutError: 查询超时(HTTP 504)。 DependencyError: 数据库故障。 """ - try: - orm = await asyncio.wait_for( - self._repos.audit_log.get_by_audit_log_id(log_id), - timeout=5.0, - ) - if orm is None: - return None - return orm_to_audit_log(orm) - except TimeoutError as exc: - raise OperationTimeoutError( - timeout_ms=5000, - message="审计日志查询超时", - ) from exc - except IntegrityError as exc: - raise self._translate_db_error(exc, "channel_audit_log") from exc - except SQLAlchemyError as exc: - raise self._translate_db_error(exc, "channel_audit_log") from exc - except (NotFoundError, ConflictError, DependencyError): - # 契约层错误:mappers 翻译的 DependencyError 与本适配器抛出的 NotFoundError/ConflictError - # 直接放行,不二次翻译,保留原始异常链与错误码 - raise - except Exception as exc: - # 兜底:mappers 调用过程中可能抛出 TypeError 等非契约异常,翻译为 DependencyError - # 防止穿透至核心层(INV-7);保留异常链以便追踪 - await self._logger.error( - "channel_audit_log_get_failed", - resource="channel_audit_log", - error=str(exc), - ) - raise DependencyError("channel_audit_log", Error(str(exc))) from exc + async with self._session_scope(None) as (_, repos, _): + try: + orm = await asyncio.wait_for( + repos.audit_log.get_by_audit_log_id(log_id), + timeout=5.0, + ) + if orm is None: + return None + return orm_to_audit_log(orm) + except TimeoutError as exc: + raise OperationTimeoutError( + timeout_ms=5000, + message="审计日志查询超时", + ) from exc + except IntegrityError as exc: + raise self._translate_db_error(exc, "channel_audit_log") from exc + except SQLAlchemyError as exc: + raise self._translate_db_error(exc, "channel_audit_log") from exc + except (NotFoundError, ConflictError, DependencyError): + # 契约层错误:mappers 翻译的 DependencyError 与本适配器抛出的 NotFoundError/ConflictError + # 直接放行,不二次翻译,保留原始异常链与错误码 + raise + except Exception as exc: + # 兜底:mappers 调用过程中可能抛出 TypeError 等非契约异常,翻译为 DependencyError + # 防止穿透至核心层(INV-7);保留异常链以便追踪 + await self._logger.error( + "channel_audit_log_get_failed", + resource="channel_audit_log", + error=str(exc), + ) + raise DependencyError("channel_audit_log", Error(str(exc))) from exc async def getAuditLogStats( self, @@ -2090,47 +2107,48 @@ class ChannelPersistenceAdapter( TimeoutError: 查询超时(HTTP 504)。 DependencyError: 数据库故障。 """ - try: - stats_data = await asyncio.wait_for( - self._repos.audit_log.get_stats( - operation=query.operation_type.value if query.operation_type is not None else None, - operator=query.operator, - target_channel=query.target_channel if query.target_channel is not None else None, - target_account=query.target_account, - start_time=_to_naive_utc(query.start_time), - end_time=_to_naive_utc(query.end_time), - ), - timeout=5.0, - ) - return AuditLogStats( - total=stats_data["total"], - by_operation_type=stats_data["by_operation"], - by_result=stats_data["by_result"], - time_range_start=stats_data["time_start"], - time_range_end=stats_data["time_end"], - ) - except TimeoutError as exc: - raise OperationTimeoutError( - timeout_ms=5000, - message="审计日志统计超时", - ) from exc - except IntegrityError as exc: - raise self._translate_db_error(exc, "channel_audit_log") from exc - except SQLAlchemyError as exc: - raise self._translate_db_error(exc, "channel_audit_log") from exc - except (NotFoundError, ConflictError, DependencyError): - # 契约层错误:mappers 翻译的 DependencyError 与本适配器抛出的 NotFoundError/ConflictError - # 直接放行,不二次翻译,保留原始异常链与错误码 - raise - except Exception as exc: - # 兜底:mappers 调用过程中可能抛出 TypeError 等非契约异常,翻译为 DependencyError - # 防止穿透至核心层(INV-7);保留异常链以便追踪 - await self._logger.error( - "channel_audit_log_get_stats_failed", - resource="channel_audit_log", - error=str(exc), - ) - raise DependencyError("channel_audit_log", Error(str(exc))) from exc + async with self._session_scope(None) as (_, repos, _): + try: + stats_data = await asyncio.wait_for( + repos.audit_log.get_stats( + operation=query.operation_type.value if query.operation_type is not None else None, + operator=query.operator, + target_channel=query.target_channel if query.target_channel is not None else None, + target_account=query.target_account, + start_time=_to_naive_utc(query.start_time), + end_time=_to_naive_utc(query.end_time), + ), + timeout=5.0, + ) + return AuditLogStats( + total=stats_data["total"], + by_operation_type=stats_data["by_operation"], + by_result=stats_data["by_result"], + time_range_start=stats_data["time_start"], + time_range_end=stats_data["time_end"], + ) + except TimeoutError as exc: + raise OperationTimeoutError( + timeout_ms=5000, + message="审计日志统计超时", + ) from exc + except IntegrityError as exc: + raise self._translate_db_error(exc, "channel_audit_log") from exc + except SQLAlchemyError as exc: + raise self._translate_db_error(exc, "channel_audit_log") from exc + except (NotFoundError, ConflictError, DependencyError): + # 契约层错误:mappers 翻译的 DependencyError 与本适配器抛出的 NotFoundError/ConflictError + # 直接放行,不二次翻译,保留原始异常链与错误码 + raise + except Exception as exc: + # 兜底:mappers 调用过程中可能抛出 TypeError 等非契约异常,翻译为 DependencyError + # 防止穿透至核心层(INV-7);保留异常链以便追踪 + await self._logger.error( + "channel_audit_log_get_stats_failed", + resource="channel_audit_log", + error=str(exc), + ) + raise DependencyError("channel_audit_log", Error(str(exc))) from exc # ═══════════════════════════════════════════════════════════════════════════ # Outbox CRUD @@ -2163,70 +2181,70 @@ class ChannelPersistenceAdapter( ConflictError: 唯一约束冲突。 DependencyError: 数据库故障。 """ - commit = self._should_commit(tx) - try: - # channel_account_id 为业务标识字符串(非 ORM 主键),需通过 - # (channel_type, account_id) 唯一约束查询 ORM 主键 int。 - # 与 saveChannelSession 的 account 解析模式保持一致。 - account_orm = await self._repos.account.get_by_type_and_account(cmd.channel_type, cmd.channel_account_id) - if account_orm is None: - raise NotFoundError("channel_account", cmd.channel_account_id) - account_pk = account_orm.id + async with self._session_scope(tx) as (db, repos, commit): try: - message_id = int(cmd.message_id) - except (TypeError, ValueError) as exc: - raise NotFoundError("message", cmd.message_id) from exc - msg_stmt = select(MessageORM.conversation_id).where(MessageORM.id == message_id) - msg_result = await self._db.execute(msg_stmt) - conversation_id = msg_result.scalar_one_or_none() - if conversation_id is None: - raise NotFoundError("message", cmd.message_id) - # cmd.channel_session_id 为业务标识 UUID 字符串,解析为 ORM - # 主键 int 后写入 ORM 列。未提供时为 NULL,重试 worker 回退 - # 到账户下最近会话查询(outbox_retry_worker._resolvePeerId)。 - channel_session_pk: int | None = None - if cmd.channel_session_id: - session_orm = await self._repos.session.get_by_session_id(cmd.channel_session_id) - if session_orm is None: - raise NotFoundError("channel_session", cmd.channel_session_id) - channel_session_pk = session_orm.id - now = utc_now_naive() - ttl_seconds = self._outbox_config.ttl_seconds - max_retry = self._outbox_config.max_retry - data = outbox_entry_for_write( - { - "outbox_id": uuid.uuid4().hex, - "message_id": message_id, - "account_id": account_pk, - "conversation_id": conversation_id, - "channel_session_id": channel_session_pk, - "status": "pending", - "durability_policy": cmd.durability_policy, - "max_retry": max_retry, - "expires_at": now + timedelta(seconds=ttl_seconds), - "trace_id": cmd.trace_id, - "stream_aborted_at_chunk": cmd.stream_aborted_at_chunk, - } - ) - orm = await self._repos.outbox.create(data, commit=commit) - return OutboxId(orm.outbox_id) - except IntegrityError as exc: - raise self._translate_db_error(exc, "channel_outbox") from exc - except SQLAlchemyError as exc: - raise self._translate_db_error(exc, "channel_outbox") from exc - except (NotFoundError, ConflictError, DependencyError): - # 契约层错误:mappers 翻译的 DependencyError 与本适配器抛出的 NotFoundError/ConflictError - # 直接放行,不二次翻译,保留原始异常链与错误码 - raise - except Exception as exc: - # 兜底:mappers 调用过程中可能抛出 TypeError 等非契约异常,翻译为 DependencyError - # 防止穿透至核心层(INV-7);保留异常链以便追踪 - await self._logger.error( - "channel_outbox_save_entry_failed", - resource="channel_outbox", - error=str(exc), - ) - raise DependencyError("channel_outbox", Error(str(exc))) from exc + # channel_account_id 为业务标识字符串(非 ORM 主键),需通过 + # (channel_type, account_id) 唯一约束查询 ORM 主键 int。 + # 与 saveChannelSession 的 account 解析模式保持一致。 + account_orm = await repos.account.get_by_type_and_account(cmd.channel_type, cmd.channel_account_id) + if account_orm is None: + raise NotFoundError("channel_account", cmd.channel_account_id) + account_pk = account_orm.id + try: + message_id = int(cmd.message_id) + except (TypeError, ValueError) as exc: + raise NotFoundError("message", cmd.message_id) from exc + msg_stmt = select(MessageORM.conversation_id).where(MessageORM.id == message_id) + msg_result = await db.execute(msg_stmt) + conversation_id = msg_result.scalar_one_or_none() + if conversation_id is None: + raise NotFoundError("message", cmd.message_id) + # cmd.channel_session_id 为业务标识 UUID 字符串,解析为 ORM + # 主键 int 后写入 ORM 列。未提供时为 NULL,重试 worker 回退 + # 到账户下最近会话查询(outbox_retry_worker._resolvePeerId)。 + channel_session_pk: int | None = None + if cmd.channel_session_id: + session_orm = await repos.session.get_by_session_id(cmd.channel_session_id) + if session_orm is None: + raise NotFoundError("channel_session", cmd.channel_session_id) + channel_session_pk = session_orm.id + now = utc_now_naive() + ttl_seconds = self._outbox_config.ttl_seconds + max_retry = self._outbox_config.max_retry + data = outbox_entry_for_write( + { + "outbox_id": uuid.uuid4().hex, + "message_id": message_id, + "account_id": account_pk, + "conversation_id": conversation_id, + "channel_session_id": channel_session_pk, + "status": "pending", + "durability_policy": cmd.durability_policy, + "max_retry": max_retry, + "expires_at": now + timedelta(seconds=ttl_seconds), + "trace_id": cmd.trace_id, + "stream_aborted_at_chunk": cmd.stream_aborted_at_chunk, + } + ) + orm = await repos.outbox.create(data, commit=commit) + return OutboxId(orm.outbox_id) + except IntegrityError as exc: + raise self._translate_db_error(exc, "channel_outbox") from exc + except SQLAlchemyError as exc: + raise self._translate_db_error(exc, "channel_outbox") from exc + except (NotFoundError, ConflictError, DependencyError): + # 契约层错误:mappers 翻译的 DependencyError 与本适配器抛出的 NotFoundError/ConflictError + # 直接放行,不二次翻译,保留原始异常链与错误码 + raise + except Exception as exc: + # 兜底:mappers 调用过程中可能抛出 TypeError 等非契约异常,翻译为 DependencyError + # 防止穿透至核心层(INV-7);保留异常链以便追踪 + await self._logger.error( + "channel_outbox_save_entry_failed", + resource="channel_outbox", + error=str(exc), + ) + raise DependencyError("channel_outbox", Error(str(exc))) from exc async def getOutboxEntry(self, outbox_id: str) -> OutboxEntry | None: """按 outbox_id 查询发件箱条目;不存在返回 None。 @@ -2234,31 +2252,32 @@ class ChannelPersistenceAdapter( Raises: DependencyError: 数据库故障。 """ - try: - orm = await self._repos.outbox.get_by_outbox_id(outbox_id) - if orm is None: - return None - account_orm = await self._repos.account.get_by_id(orm.account_id) - if account_orm is None: - raise NotFoundError("channel_account", str(orm.account_id)) - return orm_to_outbox_entry(orm, account_orm) - except IntegrityError as exc: - raise self._translate_db_error(exc, "channel_outbox") from exc - except SQLAlchemyError as exc: - raise self._translate_db_error(exc, "channel_outbox") from exc - except (NotFoundError, ConflictError, DependencyError): - # 契约层错误:mappers 翻译的 DependencyError 与本适配器抛出的 NotFoundError/ConflictError - # 直接放行,不二次翻译,保留原始异常链与错误码 - raise - except Exception as exc: - # 兜底:mappers 调用过程中可能抛出 TypeError 等非契约异常,翻译为 DependencyError - # 防止穿透至核心层(INV-7);保留异常链以便追踪 - await self._logger.error( - "channel_outbox_get_entry_failed", - resource="channel_outbox", - error=str(exc), - ) - raise DependencyError("channel_outbox", Error(str(exc))) from exc + async with self._session_scope(None) as (_, repos, _): + try: + orm = await repos.outbox.get_by_outbox_id(outbox_id) + if orm is None: + return None + account_orm = await repos.account.get_by_id(orm.account_id) + if account_orm is None: + raise NotFoundError("channel_account", str(orm.account_id)) + return orm_to_outbox_entry(orm, account_orm) + except IntegrityError as exc: + raise self._translate_db_error(exc, "channel_outbox") from exc + except SQLAlchemyError as exc: + raise self._translate_db_error(exc, "channel_outbox") from exc + except (NotFoundError, ConflictError, DependencyError): + # 契约层错误:mappers 翻译的 DependencyError 与本适配器抛出的 NotFoundError/ConflictError + # 直接放行,不二次翻译,保留原始异常链与错误码 + raise + except Exception as exc: + # 兜底:mappers 调用过程中可能抛出 TypeError 等非契约异常,翻译为 DependencyError + # 防止穿透至核心层(INV-7);保留异常链以便追踪 + await self._logger.error( + "channel_outbox_get_entry_failed", + resource="channel_outbox", + error=str(exc), + ) + raise DependencyError("channel_outbox", Error(str(exc))) from exc async def getOutboxEntryByChannelMsgId( self, @@ -2272,31 +2291,32 @@ class ChannelPersistenceAdapter( Raises: DependencyError: 数据库故障。 """ - try: - orm = await self._repos.outbox.get_by_channel_msg_id(channel_msg_id) - if orm is None: - return None - account_orm = await self._repos.account.get_by_id(orm.account_id) - if account_orm is None: - raise NotFoundError("channel_account", str(orm.account_id)) - return orm_to_outbox_entry(orm, account_orm) - except IntegrityError as exc: - raise self._translate_db_error(exc, "channel_outbox") from exc - except SQLAlchemyError as exc: - raise self._translate_db_error(exc, "channel_outbox") from exc - except (NotFoundError, ConflictError, DependencyError): - # 契约层错误:mappers 翻译的 DependencyError 与本适配器抛出的 NotFoundError/ConflictError - # 直接放行,不二次翻译,保留原始异常链与错误码 - raise - except Exception as exc: - # 兜底:mappers 调用过程中可能抛出 TypeError 等非契约异常,翻译为 DependencyError - # 防止穿透至核心层(INV-7);保留异常链以便追踪 - await self._logger.error( - "channel_outbox_get_entry_by_channel_msg_id_failed", - resource="channel_outbox", - error=str(exc), - ) - raise DependencyError("channel_outbox", Error(str(exc))) from exc + async with self._session_scope(None) as (_, repos, _): + try: + orm = await repos.outbox.get_by_channel_msg_id(channel_msg_id) + if orm is None: + return None + account_orm = await repos.account.get_by_id(orm.account_id) + if account_orm is None: + raise NotFoundError("channel_account", str(orm.account_id)) + return orm_to_outbox_entry(orm, account_orm) + except IntegrityError as exc: + raise self._translate_db_error(exc, "channel_outbox") from exc + except SQLAlchemyError as exc: + raise self._translate_db_error(exc, "channel_outbox") from exc + except (NotFoundError, ConflictError, DependencyError): + # 契约层错误:mappers 翻译的 DependencyError 与本适配器抛出的 NotFoundError/ConflictError + # 直接放行,不二次翻译,保留原始异常链与错误码 + raise + except Exception as exc: + # 兜底:mappers 调用过程中可能抛出 TypeError 等非契约异常,翻译为 DependencyError + # 防止穿透至核心层(INV-7);保留异常链以便追踪 + await self._logger.error( + "channel_outbox_get_entry_by_channel_msg_id_failed", + resource="channel_outbox", + error=str(exc), + ) + raise DependencyError("channel_outbox", Error(str(exc))) from exc async def updateOutboxEntry( self, @@ -2309,7 +2329,7 @@ class ChannelPersistenceAdapter( 补偿阶段聚合根内部计算的重试状态通过本方法完整持久化。 通过 ``entry.version`` 实现乐观锁:若持久化版本与 ``entry.version`` 不一致 则抛出 ``ConflictError``。``expected_status`` 提供双重校验:若持久化 - 条目当前状态与 ``expected_status`` 不一致则抛 ``IdempotencyConflictError``, + 条目当前状态与 ``expected_status`` 不一致则抛 ``ConflictError``, 供调用方决策(O-06/H-9)。 实现要点: @@ -2318,6 +2338,8 @@ class ChannelPersistenceAdapter( channel_msg_id),绕过仓储 ``update`` 方法跳过 None 值的限制。 - ``version`` 由本方法递增(仓储 ``update`` 方法的 updatable_fields 不含 version),再委托仓储 ``update`` 完成提交与刷新。 + - 聚合根状态变更方法不递增 version,version 仅由持久化层管理, + 支持单次持久化前多次内存状态转换(如 markFailed + markDead)。 Args: entry: 发件箱条目(含完整字段)。 @@ -2330,53 +2352,53 @@ class ChannelPersistenceAdapter( IdempotencyConflictError: ``expected_status`` 与持久化状态不匹配。 DependencyError: 数据库故障。 """ - commit = self._should_commit(tx) - try: - orm = await self._repos.outbox.get_by_outbox_id(entry.outbox_id, for_update=True) - if orm is None: - raise NotFoundError("outbox_entry", entry.outbox_id) - if orm.version != entry.version: - raise ConflictError("outbox_entry") - if expected_status is not None and orm.status != expected_status.value: - raise ConflictError("outbox_entry") - orm.status = entry.status.value - orm.retry_count = entry.retry_count - orm.next_retry_at = entry.next_retry_at - orm.last_error = entry.last_error - orm.channel_msg_id = entry.channel_msg_id - orm.latency_ms = entry.latency_ms - orm.funnel_node = entry.funnel_node - orm.sent_at = entry.sent_at - orm.last_retry_at = entry.last_retry_at - orm.idempotency_key = entry.idempotency_key - orm.channel_request_id = entry.channel_request_id - orm.partial_failure = entry.partial_failure - orm.stream_aborted_at_chunk = entry.stream_aborted_at_chunk - orm.degraded_reason = entry.degraded_reason - orm.delivered_parts = list(entry.delivered_parts) if entry.delivered_parts else [] - orm.version += 1 - updated = await self._repos.outbox.update(orm, {}, commit=commit) - account_orm = await self._repos.account.get_by_id(updated.account_id) - if account_orm is None: - raise NotFoundError("channel_account", str(updated.account_id)) - return orm_to_outbox_entry(updated, account_orm) - except IntegrityError as exc: - raise self._translate_db_error(exc, "channel_outbox") from exc - except SQLAlchemyError as exc: - raise self._translate_db_error(exc, "channel_outbox") from exc - except (NotFoundError, ConflictError, DependencyError): - # 契约层错误:mappers 翻译的 DependencyError 与本适配器抛出的 NotFoundError/ConflictError - # 直接放行,不二次翻译,保留原始异常链与错误码 - raise - except Exception as exc: - # 兜底:mappers 调用过程中可能抛出 TypeError 等非契约异常,翻译为 DependencyError - # 防止穿透至核心层(INV-7);保留异常链以便追踪 - await self._logger.error( - "channel_outbox_update_entry_failed", - resource="channel_outbox", - error=str(exc), - ) - raise DependencyError("channel_outbox", Error(str(exc))) from exc + async with self._session_scope(tx) as (_, repos, commit): + try: + orm = await repos.outbox.get_by_outbox_id(entry.outbox_id, for_update=True) + if orm is None: + raise NotFoundError("outbox_entry", entry.outbox_id) + if orm.version != entry.version: + raise ConflictError("outbox_entry") + if expected_status is not None and orm.status != expected_status.value: + raise ConflictError("outbox_entry") + orm.status = entry.status.value + orm.retry_count = entry.retry_count + orm.next_retry_at = _to_naive_utc(entry.next_retry_at) + orm.last_error = entry.last_error + orm.channel_msg_id = entry.channel_msg_id + orm.latency_ms = entry.latency_ms + orm.funnel_node = entry.funnel_node + orm.sent_at = _to_naive_utc(entry.sent_at) + orm.last_retry_at = _to_naive_utc(entry.last_retry_at) + orm.idempotency_key = entry.idempotency_key + orm.channel_request_id = entry.channel_request_id + orm.partial_failure = entry.partial_failure + orm.stream_aborted_at_chunk = entry.stream_aborted_at_chunk + orm.degraded_reason = entry.degraded_reason + orm.delivered_parts = list(entry.delivered_parts) if entry.delivered_parts else [] + orm.version += 1 + updated = await repos.outbox.update(orm, {}, commit=commit) + account_orm = await repos.account.get_by_id(updated.account_id) + if account_orm is None: + raise NotFoundError("channel_account", str(updated.account_id)) + return orm_to_outbox_entry(updated, account_orm) + except IntegrityError as exc: + raise self._translate_db_error(exc, "channel_outbox") from exc + except SQLAlchemyError as exc: + raise self._translate_db_error(exc, "channel_outbox") from exc + except (NotFoundError, ConflictError, DependencyError): + # 契约层错误:mappers 翻译的 DependencyError 与本适配器抛出的 NotFoundError/ConflictError + # 直接放行,不二次翻译,保留原始异常链与错误码 + raise + except Exception as exc: + # 兜底:mappers 调用过程中可能抛出 TypeError 等非契约异常,翻译为 DependencyError + # 防止穿透至核心层(INV-7);保留异常链以便追踪 + await self._logger.error( + "channel_outbox_update_entry_failed", + resource="channel_outbox", + error=str(exc), + ) + raise DependencyError("channel_outbox", Error(str(exc))) from exc async def listPendingOutboxEntries(self, limit: int = 100) -> tuple[OutboxEntry, ...]: """列出 PENDING 状态的发件箱条目,供 Worker 拉取投递。 @@ -2384,33 +2406,34 @@ class ChannelPersistenceAdapter( Raises: DependencyError: 数据库故障。 """ - try: - orms = await self._repos.outbox.list_pending(limit=limit) - # 批量查询关联账户 ORM,避免 N+1 查询 - account_ids = {orm.account_id for orm in orms} - account_orms_map: dict[int, ChannelAccountORM] = {} - if account_ids: - stmt = select(ChannelAccountORM).where(ChannelAccountORM.id.in_(account_ids)) - result = await self._db.execute(stmt) - account_orms_map = {a.id: a for a in result.scalars().all()} - return tuple(orm_to_outbox_entry(orm, account_orms_map.get(orm.account_id)) for orm in orms) - except IntegrityError as exc: - raise self._translate_db_error(exc, "channel_outbox") from exc - except SQLAlchemyError as exc: - raise self._translate_db_error(exc, "channel_outbox") from exc - except (NotFoundError, ConflictError, DependencyError): - # 契约层错误:mappers 翻译的 DependencyError 与本适配器抛出的 NotFoundError/ConflictError - # 直接放行,不二次翻译,保留原始异常链与错误码 - raise - except Exception as exc: - # 兜底:mappers 调用过程中可能抛出 TypeError 等非契约异常,翻译为 DependencyError - # 防止穿透至核心层(INV-7);保留异常链以便追踪 - await self._logger.error( - "channel_outbox_list_pending_entries_failed", - resource="channel_outbox", - error=str(exc), - ) - raise DependencyError("channel_outbox", Error(str(exc))) from exc + async with self._session_scope(None) as (db, repos, _): + try: + orms = await repos.outbox.list_pending(limit=limit) + # 批量查询关联账户 ORM,避免 N+1 查询 + account_ids = {orm.account_id for orm in orms} + account_orms_map: dict[int, ChannelAccountORM] = {} + if account_ids: + stmt = select(ChannelAccountORM).where(ChannelAccountORM.id.in_(account_ids)) + result = await db.execute(stmt) + account_orms_map = {a.id: a for a in result.scalars().all()} + return tuple(orm_to_outbox_entry(orm, account_orms_map.get(orm.account_id)) for orm in orms) + except IntegrityError as exc: + raise self._translate_db_error(exc, "channel_outbox") from exc + except SQLAlchemyError as exc: + raise self._translate_db_error(exc, "channel_outbox") from exc + except (NotFoundError, ConflictError, DependencyError): + # 契约层错误:mappers 翻译的 DependencyError 与本适配器抛出的 NotFoundError/ConflictError + # 直接放行,不二次翻译,保留原始异常链与错误码 + raise + except Exception as exc: + # 兜底:mappers 调用过程中可能抛出 TypeError 等非契约异常,翻译为 DependencyError + # 防止穿透至核心层(INV-7);保留异常链以便追踪 + await self._logger.error( + "channel_outbox_list_pending_entries_failed", + resource="channel_outbox", + error=str(exc), + ) + raise DependencyError("channel_outbox", Error(str(exc))) from exc async def listSentUnconfirmedOutboxEntries(self, limit: int = 100) -> tuple[OutboxEntry, ...]: """列出 SENT_UNCONFIRMED 状态的发件箱条目。 @@ -2420,33 +2443,34 @@ class ChannelPersistenceAdapter( Raises: DependencyError: 数据库故障。 """ - try: - orms = await self._repos.outbox.list_sent_unconfirmed(limit=limit) - # 批量查询关联账户 ORM,避免 N+1 查询 - account_ids = {orm.account_id for orm in orms} - account_orms_map: dict[int, ChannelAccountORM] = {} - if account_ids: - stmt = select(ChannelAccountORM).where(ChannelAccountORM.id.in_(account_ids)) - result = await self._db.execute(stmt) - account_orms_map = {a.id: a for a in result.scalars().all()} - return tuple(orm_to_outbox_entry(orm, account_orms_map.get(orm.account_id)) for orm in orms) - except IntegrityError as exc: - raise self._translate_db_error(exc, "channel_outbox") from exc - except SQLAlchemyError as exc: - raise self._translate_db_error(exc, "channel_outbox") from exc - except (NotFoundError, ConflictError, DependencyError): - # 契约层错误:mappers 翻译的 DependencyError 与本适配器抛出的 NotFoundError/ConflictError - # 直接放行,不二次翻译,保留原始异常链与错误码 - raise - except Exception as exc: - # 兜底:mappers 调用过程中可能抛出 TypeError 等非契约异常,翻译为 DependencyError - # 防止穿透至核心层(INV-7);保留异常链以便追踪 - await self._logger.error( - "channel_outbox_list_sent_unconfirmed_entries_failed", - resource="channel_outbox", - error=str(exc), - ) - raise DependencyError("channel_outbox", Error(str(exc))) from exc + async with self._session_scope(None) as (db, repos, _): + try: + orms = await repos.outbox.list_sent_unconfirmed(limit=limit) + # 批量查询关联账户 ORM,避免 N+1 查询 + account_ids = {orm.account_id for orm in orms} + account_orms_map: dict[int, ChannelAccountORM] = {} + if account_ids: + stmt = select(ChannelAccountORM).where(ChannelAccountORM.id.in_(account_ids)) + result = await db.execute(stmt) + account_orms_map = {a.id: a for a in result.scalars().all()} + return tuple(orm_to_outbox_entry(orm, account_orms_map.get(orm.account_id)) for orm in orms) + except IntegrityError as exc: + raise self._translate_db_error(exc, "channel_outbox") from exc + except SQLAlchemyError as exc: + raise self._translate_db_error(exc, "channel_outbox") from exc + except (NotFoundError, ConflictError, DependencyError): + # 契约层错误:mappers 翻译的 DependencyError 与本适配器抛出的 NotFoundError/ConflictError + # 直接放行,不二次翻译,保留原始异常链与错误码 + raise + except Exception as exc: + # 兜底:mappers 调用过程中可能抛出 TypeError 等非契约异常,翻译为 DependencyError + # 防止穿透至核心层(INV-7);保留异常链以便追踪 + await self._logger.error( + "channel_outbox_list_sent_unconfirmed_entries_failed", + resource="channel_outbox", + error=str(exc), + ) + raise DependencyError("channel_outbox", Error(str(exc))) from exc async def resolveRetryContext(self, outbox_id: str) -> RetryContext: """解析 Outbox 条目的重试上下文(FR-22)。 @@ -2478,56 +2502,62 @@ class ChannelPersistenceAdapter( NotFoundError: Outbox 条目、关联渠道账户、渠道会话或消息不存在。 DependencyError: 数据库故障或枚举字段非法值。 """ - try: - outbox_orm = await self._db.scalar( - select(ChannelOutboxEntryORM).where(ChannelOutboxEntryORM.outbox_id == outbox_id) - ) - if outbox_orm is None: - raise NotFoundError("outbox_entry", outbox_id) + async with self._session_scope(None) as (db, _, _): + try: + outbox_orm = await db.scalar( + select(ChannelOutboxEntryORM).where(ChannelOutboxEntryORM.outbox_id == outbox_id) + ) + if outbox_orm is None: + raise NotFoundError("outbox_entry", outbox_id) - account_pk = outbox_orm.account_id - message_pk = outbox_orm.message_id - channel_session_id = outbox_orm.channel_session_id + account_pk = outbox_orm.account_id + message_pk = outbox_orm.message_id + channel_session_id = outbox_orm.channel_session_id - account_orm = await self._db.scalar(select(ChannelAccountORM).where(ChannelAccountORM.id == account_pk)) - if account_orm is None: - raise NotFoundError("channel_account", str(account_pk)) + account_orm = await db.scalar(select(ChannelAccountORM).where(ChannelAccountORM.id == account_pk)) + if account_orm is None: + raise NotFoundError("channel_account", str(account_pk)) - # 通过 orm_to_channel_account 复用 _enum helper 安全构造枚举, - # 避免非法 channel_type 值穿透为原生 ValueError(INV-7)。 - account_dto = orm_to_channel_account(account_orm) + # 通过 orm_to_channel_account 复用 _enum helper 安全构造枚举, + # 避免非法 channel_type 值穿透为原生 ValueError(INV-7)。 + account_dto = orm_to_channel_account(account_orm) - peer_id = await self._resolvePeerId(account_pk, channel_session_id) + peer_id = await self._resolvePeerId(db, account_pk, channel_session_id) - message_orm = await self._db.scalar(select(MessageORM).where(MessageORM.id == message_pk)) - if message_orm is None: - raise NotFoundError("message", str(message_pk)) + message_orm = await db.scalar(select(MessageORM).where(MessageORM.id == message_pk)) + if message_orm is None: + raise NotFoundError("message", str(message_pk)) - return RetryContext( - channel_type=account_dto.channel_type, - account_id=account_dto.account_id, - peer_id=peer_id, - message=FormattedMessage(content=message_orm.content), - ) - except IntegrityError as exc: - raise self._translate_db_error(exc, "outbox_retry_context") from exc - except SQLAlchemyError as exc: - raise self._translate_db_error(exc, "outbox_retry_context") from exc - except (NotFoundError, ConflictError, DependencyError): - # 契约层错误:本适配器抛出的 NotFoundError 与 mappers 翻译的 - # DependencyError 直接放行,不二次翻译,保留原始异常链与错误码 - raise - except Exception as exc: - # 兜底:mappers 调用过程中可能抛出 TypeError 等非契约异常,翻译为 DependencyError - # 防止穿透至核心层(INV-7);保留异常链以便追踪 - await self._logger.error( - "outbox_retry_context_resolve_failed", - resource="outbox_retry_context", - error=str(exc), - ) - raise DependencyError("outbox_retry_context", Error(str(exc))) from exc + return RetryContext( + channel_type=account_dto.channel_type, + account_id=account_dto.account_id, + peer_id=peer_id, + message=FormattedMessage(content=message_orm.content), + ) + except IntegrityError as exc: + raise self._translate_db_error(exc, "outbox_retry_context") from exc + except SQLAlchemyError as exc: + raise self._translate_db_error(exc, "outbox_retry_context") from exc + except (NotFoundError, ConflictError, DependencyError): + # 契约层错误:本适配器抛出的 NotFoundError 与 mappers 翻译的 + # DependencyError 直接放行,不二次翻译,保留原始异常链与错误码 + raise + except Exception as exc: + # 兜底:mappers 调用过程中可能抛出 TypeError 等非契约异常,翻译为 DependencyError + # 防止穿透至核心层(INV-7);保留异常链以便追踪 + await self._logger.error( + "outbox_retry_context_resolve_failed", + resource="outbox_retry_context", + error=str(exc), + ) + raise DependencyError("outbox_retry_context", Error(str(exc))) from exc - async def _resolvePeerId(self, account_pk: int, channel_session_id: int | None) -> str: + async def _resolvePeerId( + self, + db: AsyncSession, + account_pk: int, + channel_session_id: int | None, + ) -> str: """解析对端 ID(FR-22)。 优先按 ``channel_session_id`` 精确定位渠道会话;若 Outbox 条目未 @@ -2535,6 +2565,8 @@ class ChannelPersistenceAdapter( 软删除的会话。 Args: + db: 当前会话范围内的 ``AsyncSession``,由调用方在 + ``_session_scope`` 内传入,避免嵌套 session。 account_pk: 渠道账户 ORM 主键。 channel_session_id: 渠道会话 ORM 主键(可选)。 @@ -2546,13 +2578,11 @@ class ChannelPersistenceAdapter( DependencyError: 数据库故障。 """ if channel_session_id is not None: - session_orm = await self._db.scalar( - select(ChannelSessionORM).where(ChannelSessionORM.id == channel_session_id) - ) + session_orm = await db.scalar(select(ChannelSessionORM).where(ChannelSessionORM.id == channel_session_id)) if session_orm is not None: return session_orm.peer_id - session_orm = await self._db.scalar( + session_orm = await db.scalar( select(ChannelSessionORM) .where( ChannelSessionORM.account_id == account_pk, @@ -2663,36 +2693,37 @@ class ChannelPersistenceAdapter( Raises: DependencyError: 数据库故障。 """ - try: - stmt = ( - select(ChannelOutboxEntryORM, ChannelAccountORM) - .join(ChannelAccountORM, ChannelOutboxEntryORM.account_id == ChannelAccountORM.id) - .where(ChannelOutboxEntryORM.is_deleted == 0) - ) - stmt = self._applyOutboxQueryFilter(stmt, query_filter) - if stmt is None: - return () - stmt = stmt.order_by(ChannelOutboxEntryORM.created_at.desc()).limit(limit).offset(offset) - result = await self._db.execute(stmt) - rows = result.all() - return tuple(orm_to_outbox_entry(row[0], row[1]) for row in rows) - except IntegrityError as exc: - raise self._translate_db_error(exc, "channel_outbox") from exc - except SQLAlchemyError as exc: - raise self._translate_db_error(exc, "channel_outbox") from exc - except (NotFoundError, ConflictError, DependencyError): - # 契约层错误:mappers 翻译的 DependencyError 直接放行,不二次翻译, - # 保留原始异常链与错误码 - raise - except Exception as exc: - # 兜底:mappers 调用过程中可能抛出 TypeError 等非契约异常,翻译为 - # DependencyError 防止穿透至核心层(INV-7);保留异常链以便追踪 - await self._logger.error( - "channel_outbox_list_entries_failed", - resource="channel_outbox", - error=str(exc), - ) - raise DependencyError("channel_outbox", Error(str(exc))) from exc + async with self._session_scope(None) as (db, _, _): + try: + stmt = ( + select(ChannelOutboxEntryORM, ChannelAccountORM) + .join(ChannelAccountORM, ChannelOutboxEntryORM.account_id == ChannelAccountORM.id) + .where(ChannelOutboxEntryORM.is_deleted == 0) + ) + stmt = self._applyOutboxQueryFilter(stmt, query_filter) + if stmt is None: + return () + stmt = stmt.order_by(ChannelOutboxEntryORM.created_at.desc()).limit(limit).offset(offset) + result = await db.execute(stmt) + rows = result.all() + return tuple(orm_to_outbox_entry(row[0], row[1]) for row in rows) + except IntegrityError as exc: + raise self._translate_db_error(exc, "channel_outbox") from exc + except SQLAlchemyError as exc: + raise self._translate_db_error(exc, "channel_outbox") from exc + except (NotFoundError, ConflictError, DependencyError): + # 契约层错误:mappers 翻译的 DependencyError 直接放行,不二次翻译, + # 保留原始异常链与错误码 + raise + except Exception as exc: + # 兜底:mappers 调用过程中可能抛出 TypeError 等非契约异常,翻译为 + # DependencyError 防止穿透至核心层(INV-7);保留异常链以便追踪 + await self._logger.error( + "channel_outbox_list_entries_failed", + resource="channel_outbox", + error=str(exc), + ) + raise DependencyError("channel_outbox", Error(str(exc))) from exc async def listOutboxEntriesByIds( self, @@ -2708,38 +2739,39 @@ class ChannelPersistenceAdapter( """ if not outbox_ids: return () - try: - stmt = ( - select(ChannelOutboxEntryORM) - .where( - ChannelOutboxEntryORM.is_deleted == 0, - ChannelOutboxEntryORM.outbox_id.in_(outbox_ids), + async with self._session_scope(None) as (db, _, _): + try: + stmt = ( + select(ChannelOutboxEntryORM) + .where( + ChannelOutboxEntryORM.is_deleted == 0, + ChannelOutboxEntryORM.outbox_id.in_(outbox_ids), + ) + .order_by(ChannelOutboxEntryORM.created_at.desc()) ) - .order_by(ChannelOutboxEntryORM.created_at.desc()) - ) - result = await self._db.execute(stmt) - orms = result.scalars().all() - # 批量查询关联账户 ORM,避免 N+1 查询 - account_ids = {orm.account_id for orm in orms} - account_orms_map: dict[int, ChannelAccountORM] = {} - if account_ids: - stmt = select(ChannelAccountORM).where(ChannelAccountORM.id.in_(account_ids)) - result = await self._db.execute(stmt) - account_orms_map = {a.id: a for a in result.scalars().all()} - return tuple(orm_to_outbox_entry(orm, account_orms_map.get(orm.account_id)) for orm in orms) - except IntegrityError as exc: - raise self._translate_db_error(exc, "channel_outbox") from exc - except SQLAlchemyError as exc: - raise self._translate_db_error(exc, "channel_outbox") from exc - except (NotFoundError, ConflictError, DependencyError): - raise - except Exception as exc: - await self._logger.error( - "channel_outbox_list_entries_by_ids_failed", - resource="channel_outbox", - error=str(exc), - ) - raise DependencyError("channel_outbox", Error(str(exc))) from exc + result = await db.execute(stmt) + orms = result.scalars().all() + # 批量查询关联账户 ORM,避免 N+1 查询 + account_ids = {orm.account_id for orm in orms} + account_orms_map: dict[int, ChannelAccountORM] = {} + if account_ids: + stmt = select(ChannelAccountORM).where(ChannelAccountORM.id.in_(account_ids)) + result = await db.execute(stmt) + account_orms_map = {a.id: a for a in result.scalars().all()} + return tuple(orm_to_outbox_entry(orm, account_orms_map.get(orm.account_id)) for orm in orms) + except IntegrityError as exc: + raise self._translate_db_error(exc, "channel_outbox") from exc + except SQLAlchemyError as exc: + raise self._translate_db_error(exc, "channel_outbox") from exc + except (NotFoundError, ConflictError, DependencyError): + raise + except Exception as exc: + await self._logger.error( + "channel_outbox_list_entries_by_ids_failed", + resource="channel_outbox", + error=str(exc), + ) + raise DependencyError("channel_outbox", Error(str(exc))) from exc async def countOutboxEntries(self, query_filter: OutboxQueryFilter) -> int: """统计符合条件的 outbox 条目数量(仅含 is_deleted=0)。 @@ -2750,26 +2782,27 @@ class ChannelPersistenceAdapter( Raises: DependencyError: 数据库故障。 """ - try: - stmt = select(func.count(ChannelOutboxEntryORM.id)).where(ChannelOutboxEntryORM.is_deleted == 0) - stmt = self._applyOutboxQueryFilter(stmt, query_filter) - if stmt is None: - return 0 - result = await self._db.execute(stmt) - return int(result.scalar() or 0) - except IntegrityError as exc: - raise self._translate_db_error(exc, "channel_outbox") from exc - except SQLAlchemyError as exc: - raise self._translate_db_error(exc, "channel_outbox") from exc - except (NotFoundError, ConflictError, DependencyError): - raise - except Exception as exc: - await self._logger.error( - "channel_outbox_count_entries_failed", - resource="channel_outbox", - error=str(exc), - ) - raise DependencyError("channel_outbox", Error(str(exc))) from exc + async with self._session_scope(None) as (db, _, _): + try: + stmt = select(func.count(ChannelOutboxEntryORM.id)).where(ChannelOutboxEntryORM.is_deleted == 0) + stmt = self._applyOutboxQueryFilter(stmt, query_filter) + if stmt is None: + return 0 + result = await db.execute(stmt) + return int(result.scalar() or 0) + except IntegrityError as exc: + raise self._translate_db_error(exc, "channel_outbox") from exc + except SQLAlchemyError as exc: + raise self._translate_db_error(exc, "channel_outbox") from exc + except (NotFoundError, ConflictError, DependencyError): + raise + except Exception as exc: + await self._logger.error( + "channel_outbox_count_entries_failed", + resource="channel_outbox", + error=str(exc), + ) + raise DependencyError("channel_outbox", Error(str(exc))) from exc async def getOutboxStats( self, @@ -2787,106 +2820,109 @@ class ChannelPersistenceAdapter( Raises: DependencyError: 数据库故障。 """ - try: + async with self._session_scope(None) as (db, _, _): + try: - def _apply_channel_filter(stmt: Select[Any]) -> Select[Any]: - if channel_type is not None or channel_account_id is not None: - stmt = stmt.join( - ChannelAccountORM, - ChannelOutboxEntryORM.account_id == ChannelAccountORM.id, + def _apply_channel_filter(stmt: Select[Any]) -> Select[Any]: + if channel_type is not None or channel_account_id is not None: + stmt = stmt.join( + ChannelAccountORM, + ChannelOutboxEntryORM.account_id == ChannelAccountORM.id, + ) + if channel_type is not None: + stmt = stmt.where(ChannelAccountORM.channel_type == channel_type) + if channel_account_id is not None: + stmt = stmt.where(ChannelAccountORM.account_id == channel_account_id) + return stmt + + status_stmt = ( + select( + ChannelOutboxEntryORM.status, + func.count(ChannelOutboxEntryORM.id), ) - if channel_type is not None: - stmt = stmt.where(ChannelAccountORM.channel_type == channel_type) - if channel_account_id is not None: - stmt = stmt.where(ChannelAccountORM.account_id == channel_account_id) - return stmt - - status_stmt = ( - select( - ChannelOutboxEntryORM.status, - func.count(ChannelOutboxEntryORM.id), + .where(ChannelOutboxEntryORM.is_deleted == 0) + .group_by(ChannelOutboxEntryORM.status) ) - .where(ChannelOutboxEntryORM.is_deleted == 0) - .group_by(ChannelOutboxEntryORM.status) - ) - status_stmt = _apply_channel_filter(status_stmt) - status_result = await self._db.execute(status_stmt) - status_counts: dict[str, int] = { - "pending": 0, - "sent": 0, - "suppressed": 0, - "failed": 0, - "sent_unconfirmed": 0, - "dead": 0, - } - total = 0 - for status, count in status_result.all(): - if status is not None and status in status_counts: - status_counts[status] = int(count) - total += int(count) + status_stmt = _apply_channel_filter(status_stmt) + status_result = await db.execute(status_stmt) + status_counts: dict[str, int] = { + "pending": 0, + "sent": 0, + "suppressed": 0, + "failed": 0, + "sent_unconfirmed": 0, + "dead": 0, + } + total = 0 + for status, count in status_result.all(): + if status is not None and status in status_counts: + status_counts[status] = int(count) + total += int(count) - top_errors_stmt = ( - select( - ChannelOutboxEntryORM.last_error, - func.count(ChannelOutboxEntryORM.id), + top_errors_stmt = ( + select( + ChannelOutboxEntryORM.last_error, + func.count(ChannelOutboxEntryORM.id), + ) + .where( + ChannelOutboxEntryORM.is_deleted == 0, + ChannelOutboxEntryORM.status.in_(("failed", "dead")), + ChannelOutboxEntryORM.last_error.is_not(None), + ) + .group_by(ChannelOutboxEntryORM.last_error) + .order_by(func.count(ChannelOutboxEntryORM.id).desc()) + .limit(5) ) - .where( + top_errors_stmt = _apply_channel_filter(top_errors_stmt) + top_errors_result = await db.execute(top_errors_stmt) + top_errors = tuple( + {"error": error, "count": int(count)} + for error, count in top_errors_result.all() + if error is not None + ) + + avg_latency_stmt = select(func.avg(ChannelOutboxEntryORM.latency_ms)).where( ChannelOutboxEntryORM.is_deleted == 0, - ChannelOutboxEntryORM.status.in_(("failed", "dead")), - ChannelOutboxEntryORM.last_error.is_not(None), + ChannelOutboxEntryORM.latency_ms.is_not(None), ) - .group_by(ChannelOutboxEntryORM.last_error) - .order_by(func.count(ChannelOutboxEntryORM.id).desc()) - .limit(5) - ) - top_errors_stmt = _apply_channel_filter(top_errors_stmt) - top_errors_result = await self._db.execute(top_errors_stmt) - top_errors = tuple( - {"error": error, "count": int(count)} for error, count in top_errors_result.all() if error is not None - ) + avg_latency_stmt = _apply_channel_filter(avg_latency_stmt) + avg_latency_result = await db.execute(avg_latency_stmt) + avg_latency_value = avg_latency_result.scalar() + avg_latency_ms = float(avg_latency_value) if avg_latency_value is not None else None - avg_latency_stmt = select(func.avg(ChannelOutboxEntryORM.latency_ms)).where( - ChannelOutboxEntryORM.is_deleted == 0, - ChannelOutboxEntryORM.latency_ms.is_not(None), - ) - avg_latency_stmt = _apply_channel_filter(avg_latency_stmt) - avg_latency_result = await self._db.execute(avg_latency_stmt) - avg_latency_value = avg_latency_result.scalar() - avg_latency_ms = float(avg_latency_value) if avg_latency_value is not None else None + oldest_pending_stmt = select(func.min(ChannelOutboxEntryORM.created_at)).where( + ChannelOutboxEntryORM.is_deleted == 0, + ChannelOutboxEntryORM.status == "pending", + ) + oldest_pending_stmt = _apply_channel_filter(oldest_pending_stmt) + oldest_pending_result = await db.execute(oldest_pending_stmt) + oldest_pending_at = oldest_pending_result.scalar() - oldest_pending_stmt = select(func.min(ChannelOutboxEntryORM.created_at)).where( - ChannelOutboxEntryORM.is_deleted == 0, - ChannelOutboxEntryORM.status == "pending", - ) - oldest_pending_stmt = _apply_channel_filter(oldest_pending_stmt) - oldest_pending_result = await self._db.execute(oldest_pending_stmt) - oldest_pending_at = oldest_pending_result.scalar() - - return OutboxStats( - total=total, - pending=status_counts["pending"], - sent=status_counts["sent"], - suppressed=status_counts["suppressed"], - failed=status_counts["failed"], - sent_unconfirmed=status_counts["sent_unconfirmed"], - dead=status_counts["dead"], - top_errors=top_errors, - avg_latency_ms=avg_latency_ms, - oldest_pending_at=oldest_pending_at, - ) - except IntegrityError as exc: - raise self._translate_db_error(exc, "channel_outbox") from exc - except SQLAlchemyError as exc: - raise self._translate_db_error(exc, "channel_outbox") from exc - except (NotFoundError, ConflictError, DependencyError): - raise - except Exception as exc: - await self._logger.error( - "channel_outbox_get_stats_failed", - resource="channel_outbox", - error=str(exc), - ) - raise DependencyError("channel_outbox", Error(str(exc))) from exc + return OutboxStats( + total=total, + pending=status_counts["pending"], + sent=status_counts["sent"], + suppressed=status_counts["suppressed"], + failed=status_counts["failed"], + sent_unconfirmed=status_counts["sent_unconfirmed"], + dead=status_counts["dead"], + top_errors=top_errors, + avg_latency_ms=avg_latency_ms, + oldest_pending_at=oldest_pending_at, + ) + except IntegrityError as exc: + raise self._translate_db_error(exc, "channel_outbox") from exc + except SQLAlchemyError as exc: + raise self._translate_db_error(exc, "channel_outbox") from exc + except (NotFoundError, ConflictError, DependencyError): + raise + except Exception as exc: + await self._logger.error( + "channel_outbox_get_stats_failed", + resource="channel_outbox", + error=str(exc), + ) + raise DependencyError("channel_outbox", Error(str(exc))) from exc async def exportDeadLetters( self, @@ -2903,44 +2939,45 @@ class ChannelPersistenceAdapter( Raises: DependencyError: 数据库故障。 """ - try: - stmt = select(ChannelOutboxEntryORM).where( - ChannelOutboxEntryORM.is_deleted == 0, - ChannelOutboxEntryORM.status == "dead", - ) - if query.channel_type is not None: - stmt = stmt.join( - ChannelAccountORM, - ChannelOutboxEntryORM.account_id == ChannelAccountORM.id, - ).where(ChannelAccountORM.channel_type == query.channel_type) - if query.created_after is not None: - stmt = stmt.where(ChannelOutboxEntryORM.created_at >= _to_naive_utc(query.created_after)) - if query.created_before is not None: - stmt = stmt.where(ChannelOutboxEntryORM.created_at <= _to_naive_utc(query.created_before)) - stmt = stmt.order_by(ChannelOutboxEntryORM.created_at.desc()).limit(query.limit) - result = await self._db.execute(stmt) - orms = result.scalars().all() - # 批量查询关联账户 ORM,避免 N+1 查询 - account_ids = {orm.account_id for orm in orms} - account_orms_map: dict[int, ChannelAccountORM] = {} - if account_ids: - stmt = select(ChannelAccountORM).where(ChannelAccountORM.id.in_(account_ids)) - result = await self._db.execute(stmt) - account_orms_map = {a.id: a for a in result.scalars().all()} - return tuple(orm_to_outbox_entry(orm, account_orms_map.get(orm.account_id)) for orm in orms) - except IntegrityError as exc: - raise self._translate_db_error(exc, "channel_outbox") from exc - except SQLAlchemyError as exc: - raise self._translate_db_error(exc, "channel_outbox") from exc - except (NotFoundError, ConflictError, DependencyError): - raise - except Exception as exc: - await self._logger.error( - "channel_outbox_export_dead_letters_failed", - resource="channel_outbox", - error=str(exc), - ) - raise DependencyError("channel_outbox", Error(str(exc))) from exc + async with self._session_scope(None) as (db, _, _): + try: + stmt = select(ChannelOutboxEntryORM).where( + ChannelOutboxEntryORM.is_deleted == 0, + ChannelOutboxEntryORM.status == "dead", + ) + if query.channel_type is not None: + stmt = stmt.join( + ChannelAccountORM, + ChannelOutboxEntryORM.account_id == ChannelAccountORM.id, + ).where(ChannelAccountORM.channel_type == query.channel_type) + if query.created_after is not None: + stmt = stmt.where(ChannelOutboxEntryORM.created_at >= _to_naive_utc(query.created_after)) + if query.created_before is not None: + stmt = stmt.where(ChannelOutboxEntryORM.created_at <= _to_naive_utc(query.created_before)) + stmt = stmt.order_by(ChannelOutboxEntryORM.created_at.desc()).limit(query.limit) + result = await db.execute(stmt) + orms = result.scalars().all() + # 批量查询关联账户 ORM,避免 N+1 查询 + account_ids = {orm.account_id for orm in orms} + account_orms_map: dict[int, ChannelAccountORM] = {} + if account_ids: + stmt = select(ChannelAccountORM).where(ChannelAccountORM.id.in_(account_ids)) + result = await db.execute(stmt) + account_orms_map = {a.id: a for a in result.scalars().all()} + return tuple(orm_to_outbox_entry(orm, account_orms_map.get(orm.account_id)) for orm in orms) + except IntegrityError as exc: + raise self._translate_db_error(exc, "channel_outbox") from exc + except SQLAlchemyError as exc: + raise self._translate_db_error(exc, "channel_outbox") from exc + except (NotFoundError, ConflictError, DependencyError): + raise + except Exception as exc: + await self._logger.error( + "channel_outbox_export_dead_letters_failed", + resource="channel_outbox", + error=str(exc), + ) + raise DependencyError("channel_outbox", Error(str(exc))) from exc async def getTrend( self, @@ -2968,75 +3005,76 @@ class ChannelPersistenceAdapter( Raises: DependencyError: 数据库故障。 """ - try: - # DB 时间列为 naive UTC,剥离 DTO 入参的 tzinfo(见 _to_naive_utc 说明)。 - start_naive = _to_naive_utc(query.start_time) - end_naive = _to_naive_utc(query.end_time) - # 根据 metric 选择时间列:queue_depth 按 created_at,其余按 updated_at。 - # 时间过滤列与分桶列必须一致,避免漏统计。 - if query.metric == "queue_depth": - time_col = ChannelOutboxEntryORM.created_at - count_expr = func.count(ChannelOutboxEntryORM.id) - elif query.metric == "retry_count": - time_col = ChannelOutboxEntryORM.updated_at - count_expr = func.coalesce(func.sum(ChannelOutboxEntryORM.retry_count), 0) - elif query.metric == "dead_count": - time_col = ChannelOutboxEntryORM.updated_at - count_expr = func.count( - case( - (ChannelOutboxEntryORM.status == "dead", 1), - else_=None, + async with self._session_scope(None) as (db, _, _): + try: + # DB 时间列为 naive UTC,剥离 DTO 入参的 tzinfo(见 _to_naive_utc 说明)。 + start_naive = _to_naive_utc(query.start_time) + end_naive = _to_naive_utc(query.end_time) + # 根据 metric 选择时间列:queue_depth 按 created_at,其余按 updated_at。 + # 时间过滤列与分桶列必须一致,避免漏统计。 + if query.metric == "queue_depth": + time_col = ChannelOutboxEntryORM.created_at + count_expr = func.count(ChannelOutboxEntryORM.id) + elif query.metric == "retry_count": + time_col = ChannelOutboxEntryORM.updated_at + count_expr = func.coalesce(func.sum(ChannelOutboxEntryORM.retry_count), 0) + elif query.metric == "dead_count": + time_col = ChannelOutboxEntryORM.updated_at + count_expr = func.count( + case( + (ChannelOutboxEntryORM.status == "dead", 1), + else_=None, + ) ) + else: + raise DependencyError( + "channel_outbox", + Error(f"unsupported metric: {query.metric}"), + ) + bucket_expr = func.date_trunc(query.granularity, time_col) + stmt = ( + select( + bucket_expr.label("bucket"), + count_expr.label("value"), + ) + .where( + ChannelOutboxEntryORM.is_deleted == 0, + time_col >= start_naive, + time_col < end_naive, + ) + .group_by(bucket_expr) + .order_by(bucket_expr) ) - else: - raise DependencyError( - "channel_outbox", - Error(f"unsupported metric: {query.metric}"), + if query.channel_type is not None or query.channel_account_id is not None: + stmt = stmt.join( + ChannelAccountORM, + ChannelOutboxEntryORM.account_id == ChannelAccountORM.id, + ) + if query.channel_type is not None: + stmt = stmt.where(ChannelAccountORM.channel_type == query.channel_type) + if query.channel_account_id is not None: + stmt = stmt.where(ChannelAccountORM.account_id == query.channel_account_id) + result = await db.execute(stmt) + return tuple( + TrendDataPoint( + timestamp=bucket, + value=int(value or 0), + ) + for bucket, value in result.all() ) - bucket_expr = func.date_trunc(query.granularity, time_col) - stmt = ( - select( - bucket_expr.label("bucket"), - count_expr.label("value"), + except IntegrityError as exc: + raise self._translate_db_error(exc, "channel_outbox") from exc + except SQLAlchemyError as exc: + raise self._translate_db_error(exc, "channel_outbox") from exc + except (NotFoundError, ConflictError, DependencyError): + raise + except Exception as exc: + await self._logger.error( + "channel_outbox_get_trend_failed", + resource="channel_outbox", + error=str(exc), ) - .where( - ChannelOutboxEntryORM.is_deleted == 0, - time_col >= start_naive, - time_col < end_naive, - ) - .group_by(bucket_expr) - .order_by(bucket_expr) - ) - if query.channel_type is not None or query.channel_account_id is not None: - stmt = stmt.join( - ChannelAccountORM, - ChannelOutboxEntryORM.account_id == ChannelAccountORM.id, - ) - if query.channel_type is not None: - stmt = stmt.where(ChannelAccountORM.channel_type == query.channel_type) - if query.channel_account_id is not None: - stmt = stmt.where(ChannelAccountORM.account_id == query.channel_account_id) - result = await self._db.execute(stmt) - return tuple( - TrendDataPoint( - timestamp=bucket, - value=int(value or 0), - ) - for bucket, value in result.all() - ) - except IntegrityError as exc: - raise self._translate_db_error(exc, "channel_outbox") from exc - except SQLAlchemyError as exc: - raise self._translate_db_error(exc, "channel_outbox") from exc - except (NotFoundError, ConflictError, DependencyError): - raise - except Exception as exc: - await self._logger.error( - "channel_outbox_get_trend_failed", - resource="channel_outbox", - error=str(exc), - ) - raise DependencyError("channel_outbox", Error(str(exc))) from exc + raise DependencyError("channel_outbox", Error(str(exc))) from exc async def getAccountStats( self, @@ -3046,44 +3084,45 @@ class ChannelPersistenceAdapter( 通过 GROUP BY channel_type, status 单次查询完成聚合。 """ - try: - stmt = ( - select( - ChannelAccountORM.channel_type, - ChannelAccountORM.status, - func.count().label("count"), + async with self._session_scope(None) as (db, _, _): + try: + stmt = ( + select( + ChannelAccountORM.channel_type, + ChannelAccountORM.status, + func.count().label("count"), + ) + .where(ChannelAccountORM.is_deleted == 0) + .group_by(ChannelAccountORM.channel_type, ChannelAccountORM.status) ) - .where(ChannelAccountORM.is_deleted == 0) - .group_by(ChannelAccountORM.channel_type, ChannelAccountORM.status) - ) - if channel_type is not None: - stmt = stmt.where(ChannelAccountORM.channel_type == channel_type) - result = await self._db.execute(stmt) + if channel_type is not None: + stmt = stmt.where(ChannelAccountORM.channel_type == channel_type) + result = await db.execute(stmt) - by_channel: dict[str, int] = {} - by_status: dict[str, int] = {} - total = 0 - for row in result.all(): - ch = row.channel_type - st = row.status - cnt = int(row.count) - by_channel[ch] = by_channel.get(ch, 0) + cnt - by_status[st] = by_status.get(st, 0) + cnt - total += cnt - return AccountStats(total=total, by_channel=by_channel, by_status=by_status) - except IntegrityError as exc: - raise self._translate_db_error(exc, "channel_account") from exc - except SQLAlchemyError as exc: - raise self._translate_db_error(exc, "channel_account") from exc - except (NotFoundError, ConflictError, DependencyError): - raise - except Exception as exc: - await self._logger.error( - "channel_account_get_account_stats_failed", - resource="channel_account", - error=str(exc), - ) - raise DependencyError("channel_account", Error(str(exc))) from exc + by_channel: dict[str, int] = {} + by_status: dict[str, int] = {} + total = 0 + for row in result.all(): + ch = row.channel_type + st = row.status + cnt = int(row.count) + by_channel[ch] = by_channel.get(ch, 0) + cnt + by_status[st] = by_status.get(st, 0) + cnt + total += cnt + return AccountStats(total=total, by_channel=by_channel, by_status=by_status) + except IntegrityError as exc: + raise self._translate_db_error(exc, "channel_account") from exc + except SQLAlchemyError as exc: + raise self._translate_db_error(exc, "channel_account") from exc + except (NotFoundError, ConflictError, DependencyError): + raise + except Exception as exc: + await self._logger.error( + "channel_account_get_account_stats_failed", + resource="channel_account", + error=str(exc), + ) + raise DependencyError("channel_account", Error(str(exc))) from exc async def getSessionStats( self, @@ -3093,44 +3132,45 @@ class ChannelPersistenceAdapter( 通过 GROUP BY channel_type, is_temporary 单次查询完成聚合。 """ - try: - stmt = ( - select( - ChannelSessionORM.channel_type, - ChannelSessionORM.is_temporary, - func.count().label("count"), + async with self._session_scope(None) as (db, _, _): + try: + stmt = ( + select( + ChannelSessionORM.channel_type, + ChannelSessionORM.is_temporary, + func.count().label("count"), + ) + .where(ChannelSessionORM.is_deleted == 0) + .group_by(ChannelSessionORM.channel_type, ChannelSessionORM.is_temporary) ) - .where(ChannelSessionORM.is_deleted == 0) - .group_by(ChannelSessionORM.channel_type, ChannelSessionORM.is_temporary) - ) - if channel_type is not None: - stmt = stmt.where(ChannelSessionORM.channel_type == channel_type) - result = await self._db.execute(stmt) + if channel_type is not None: + stmt = stmt.where(ChannelSessionORM.channel_type == channel_type) + result = await db.execute(stmt) - by_channel: dict[str, int] = {} - by_is_temporary: dict[str, int] = {"true": 0, "false": 0} - total = 0 - for row in result.all(): - ch = row.channel_type - is_tmp = "true" if row.is_temporary else "false" - cnt = int(row.count) - by_channel[ch] = by_channel.get(ch, 0) + cnt - by_is_temporary[is_tmp] += cnt - total += cnt - return SessionStats(total=total, by_channel=by_channel, by_is_temporary=by_is_temporary) - except IntegrityError as exc: - raise self._translate_db_error(exc, "channel_session") from exc - except SQLAlchemyError as exc: - raise self._translate_db_error(exc, "channel_session") from exc - except (NotFoundError, ConflictError, DependencyError): - raise - except Exception as exc: - await self._logger.error( - "channel_session_get_session_stats_failed", - resource="channel_session", - error=str(exc), - ) - raise DependencyError("channel_session", Error(str(exc))) from exc + by_channel: dict[str, int] = {} + by_is_temporary: dict[str, int] = {"true": 0, "false": 0} + total = 0 + for row in result.all(): + ch = row.channel_type + is_tmp = "true" if row.is_temporary else "false" + cnt = int(row.count) + by_channel[ch] = by_channel.get(ch, 0) + cnt + by_is_temporary[is_tmp] += cnt + total += cnt + return SessionStats(total=total, by_channel=by_channel, by_is_temporary=by_is_temporary) + except IntegrityError as exc: + raise self._translate_db_error(exc, "channel_session") from exc + except SQLAlchemyError as exc: + raise self._translate_db_error(exc, "channel_session") from exc + except (NotFoundError, ConflictError, DependencyError): + raise + except Exception as exc: + await self._logger.error( + "channel_session_get_session_stats_failed", + resource="channel_session", + error=str(exc), + ) + raise DependencyError("channel_session", Error(str(exc))) from exc async def deleteOutboxEntry( self, @@ -3150,34 +3190,34 @@ class ChannelPersistenceAdapter( Raises: DependencyError: 数据库故障。 """ - commit = self._should_commit(tx) - try: - stmt = select(ChannelOutboxEntryORM).where( - ChannelOutboxEntryORM.outbox_id == outbox_id, - ChannelOutboxEntryORM.is_deleted == 0, - ) - orm = await self._db.scalar(stmt) - if orm is None: - return False - orm.is_deleted = 1 - orm.deleted_at = utc_now_naive() - orm.updated_at = utc_now_naive() - if commit: - await self._db.commit() - return True - except IntegrityError as exc: - raise self._translate_db_error(exc, "channel_outbox") from exc - except SQLAlchemyError as exc: - raise self._translate_db_error(exc, "channel_outbox") from exc - except (NotFoundError, ConflictError, DependencyError): - raise - except Exception as exc: - await self._logger.error( - "channel_outbox_delete_entry_failed", - resource="channel_outbox", - error=str(exc), - ) - raise DependencyError("channel_outbox", Error(str(exc))) from exc + async with self._session_scope(tx) as (db, _, commit): + try: + stmt = select(ChannelOutboxEntryORM).where( + ChannelOutboxEntryORM.outbox_id == outbox_id, + ChannelOutboxEntryORM.is_deleted == 0, + ) + orm = await db.scalar(stmt) + if orm is None: + return False + orm.is_deleted = 1 + orm.deleted_at = utc_now_naive() + orm.updated_at = utc_now_naive() + if commit: + await db.commit() + return True + except IntegrityError as exc: + raise self._translate_db_error(exc, "channel_outbox") from exc + except SQLAlchemyError as exc: + raise self._translate_db_error(exc, "channel_outbox") from exc + except (NotFoundError, ConflictError, DependencyError): + raise + except Exception as exc: + await self._logger.error( + "channel_outbox_delete_entry_failed", + resource="channel_outbox", + error=str(exc), + ) + raise DependencyError("channel_outbox", Error(str(exc))) from exc async def cleanupOldDeadEntries(self, before: datetime, limit: int) -> tuple[str, ...]: """物理删除早于指定时间的 DEAD 状态发件箱条目(FR-22 死信清理)。 @@ -3191,40 +3231,42 @@ class ChannelPersistenceAdapter( Raises: DependencyError: 数据库故障。 """ - try: - # DB 时间列为 naive UTC,剥离入参的 tzinfo(见 _to_naive_utc 说明)。 - before_naive = _to_naive_utc(before) - select_stmt = ( - select(ChannelOutboxEntryORM.outbox_id) - .where( - ChannelOutboxEntryORM.status == "dead", - ChannelOutboxEntryORM.updated_at < before_naive, - ChannelOutboxEntryORM.is_deleted == 0, + async with self._session_scope(None) as (db, _, commit): + try: + # DB 时间列为 naive UTC,剥离入参的 tzinfo(见 _to_naive_utc 说明)。 + before_naive = _to_naive_utc(before) + select_stmt = ( + select(ChannelOutboxEntryORM.outbox_id) + .where( + ChannelOutboxEntryORM.status == "dead", + ChannelOutboxEntryORM.updated_at < before_naive, + ChannelOutboxEntryORM.is_deleted == 0, + ) + .limit(limit) ) - .limit(limit) - ) - result = await self._db.execute(select_stmt) - outbox_ids = [row[0] for row in result.fetchall()] - if not outbox_ids: - return () - await self._db.execute(delete(ChannelOutboxEntryORM).where(ChannelOutboxEntryORM.outbox_id.in_(outbox_ids))) - await self._db.commit() - return tuple(outbox_ids) - except IntegrityError as exc: - raise self._translate_db_error(exc, "channel_outbox") from exc - except SQLAlchemyError as exc: - raise self._translate_db_error(exc, "channel_outbox") from exc - except (NotFoundError, ConflictError, DependencyError): - # 契约层错误:直接放行,不二次翻译,保留原始异常链与错误码 - raise - except Exception as exc: - # 兜底:防止非契约异常穿透至核心层(INV-7);保留异常链以便追踪 - await self._logger.error( - "channel_outbox_cleanup_old_dead_entries_failed", - resource="channel_outbox", - error=str(exc), - ) - raise DependencyError("channel_outbox", Error(str(exc))) from exc + result = await db.execute(select_stmt) + outbox_ids = [row[0] for row in result.fetchall()] + if not outbox_ids: + return () + await db.execute(delete(ChannelOutboxEntryORM).where(ChannelOutboxEntryORM.outbox_id.in_(outbox_ids))) + if commit: + await db.commit() + return tuple(outbox_ids) + except IntegrityError as exc: + raise self._translate_db_error(exc, "channel_outbox") from exc + except SQLAlchemyError as exc: + raise self._translate_db_error(exc, "channel_outbox") from exc + except (NotFoundError, ConflictError, DependencyError): + # 契约层错误:直接放行,不二次翻译,保留原始异常链与错误码 + raise + except Exception as exc: + # 兜底:防止非契约异常穿透至核心层(INV-7);保留异常链以便追踪 + await self._logger.error( + "channel_outbox_cleanup_old_dead_entries_failed", + resource="channel_outbox", + error=str(exc), + ) + raise DependencyError("channel_outbox", Error(str(exc))) from exc async def cleanupOldSentEntries(self, before: datetime, limit: int) -> tuple[str, ...]: """物理删除早于指定时间的 SENT 状态发件箱条目(FR-22 已投递清理)。 @@ -3238,40 +3280,42 @@ class ChannelPersistenceAdapter( Raises: DependencyError: 数据库故障。 """ - try: - # DB 时间列为 naive UTC,剥离入参的 tzinfo(见 _to_naive_utc 说明)。 - before_naive = _to_naive_utc(before) - select_stmt = ( - select(ChannelOutboxEntryORM.outbox_id) - .where( - ChannelOutboxEntryORM.status == "sent", - ChannelOutboxEntryORM.updated_at < before_naive, - ChannelOutboxEntryORM.is_deleted == 0, + async with self._session_scope(None) as (db, _, commit): + try: + # DB 时间列为 naive UTC,剥离入参的 tzinfo(见 _to_naive_utc 说明)。 + before_naive = _to_naive_utc(before) + select_stmt = ( + select(ChannelOutboxEntryORM.outbox_id) + .where( + ChannelOutboxEntryORM.status == "sent", + ChannelOutboxEntryORM.updated_at < before_naive, + ChannelOutboxEntryORM.is_deleted == 0, + ) + .limit(limit) ) - .limit(limit) - ) - result = await self._db.execute(select_stmt) - outbox_ids = [row[0] for row in result.fetchall()] - if not outbox_ids: - return () - await self._db.execute(delete(ChannelOutboxEntryORM).where(ChannelOutboxEntryORM.outbox_id.in_(outbox_ids))) - await self._db.commit() - return tuple(outbox_ids) - except IntegrityError as exc: - raise self._translate_db_error(exc, "channel_outbox") from exc - except SQLAlchemyError as exc: - raise self._translate_db_error(exc, "channel_outbox") from exc - except (NotFoundError, ConflictError, DependencyError): - # 契约层错误:直接放行,不二次翻译,保留原始异常链与错误码 - raise - except Exception as exc: - # 兜底:防止非契约异常穿透至核心层(INV-7);保留异常链以便追踪 - await self._logger.error( - "channel_outbox_cleanup_old_sent_entries_failed", - resource="channel_outbox", - error=str(exc), - ) - raise DependencyError("channel_outbox", Error(str(exc))) from exc + result = await db.execute(select_stmt) + outbox_ids = [row[0] for row in result.fetchall()] + if not outbox_ids: + return () + await db.execute(delete(ChannelOutboxEntryORM).where(ChannelOutboxEntryORM.outbox_id.in_(outbox_ids))) + if commit: + await db.commit() + return tuple(outbox_ids) + except IntegrityError as exc: + raise self._translate_db_error(exc, "channel_outbox") from exc + except SQLAlchemyError as exc: + raise self._translate_db_error(exc, "channel_outbox") from exc + except (NotFoundError, ConflictError, DependencyError): + # 契约层错误:直接放行,不二次翻译,保留原始异常链与错误码 + raise + except Exception as exc: + # 兜底:防止非契约异常穿透至核心层(INV-7);保留异常链以便追踪 + await self._logger.error( + "channel_outbox_cleanup_old_sent_entries_failed", + resource="channel_outbox", + error=str(exc), + ) + raise DependencyError("channel_outbox", Error(str(exc))) from exc # ═══════════════════════════════════════════════════════════════════════════ # 用户身份 CRUD @@ -3294,47 +3338,47 @@ class ChannelPersistenceAdapter( ConflictError: 唯一约束冲突。 DependencyError: 数据库故障。 """ - commit = self._should_commit(tx) - # channel_bindings 初始化:记录首渠道绑定 {channel_type: [channel_sender_id]} - # ChannelType 为 str 子类(非 Enum),直接 str() 取字符串值作为键 - if cmd.channel_type is not None and cmd.channel_sender_id: - channel_bindings: dict[str, list[str]] = {str(cmd.channel_type): [cmd.channel_sender_id]} - else: - channel_bindings = {} - data = user_identity_for_write( - { - "identity_id": uuid.uuid4().hex, - "identity_type": cmd.identity_type, - "identity_value": cmd.identity_value, - "channel_type": cmd.channel_type if cmd.channel_type is not None else None, - "channel_sender_id": cmd.channel_sender_id, - "source": cmd.source, - "user_id": int(cmd.user_id) if cmd.user_id else None, - "channel_bindings": channel_bindings, - "merged_from": [], - "confidence": cmd.confidence, - } - ) - try: - orm = await self._repos.identity.create(data, commit=commit) - return orm_to_user_identity(orm) - except IntegrityError as exc: - raise self._translate_db_error(exc, "user_identity") from exc - except SQLAlchemyError as exc: - raise self._translate_db_error(exc, "user_identity") from exc - except (NotFoundError, ConflictError, DependencyError): - # 契约层错误:mappers 翻译的 DependencyError 与本适配器抛出的 NotFoundError/ConflictError - # 直接放行,不二次翻译,保留原始异常链与错误码 - raise - except Exception as exc: - # 兜底:mappers 调用过程中可能抛出 TypeError 等非契约异常,翻译为 DependencyError - # 防止穿透至核心层(INV-7);保留异常链以便追踪 - await self._logger.error( - "user_identity_save_failed", - resource="user_identity", - error=str(exc), + async with self._session_scope(tx) as (_, repos, commit): + # channel_bindings 初始化:记录首渠道绑定 {channel_type: [channel_sender_id]} + # ChannelType 为 str 子类(非 Enum),直接 str() 取字符串值作为键 + if cmd.channel_type is not None and cmd.channel_sender_id: + channel_bindings: dict[str, list[str]] = {str(cmd.channel_type): [cmd.channel_sender_id]} + else: + channel_bindings = {} + data = user_identity_for_write( + { + "identity_id": uuid.uuid4().hex, + "identity_type": cmd.identity_type, + "identity_value": cmd.identity_value, + "channel_type": cmd.channel_type if cmd.channel_type is not None else None, + "channel_sender_id": cmd.channel_sender_id, + "source": cmd.source, + "user_id": int(cmd.user_id) if cmd.user_id else None, + "channel_bindings": channel_bindings, + "merged_from": [], + "confidence": cmd.confidence, + } ) - raise DependencyError("user_identity", Error(str(exc))) from exc + try: + orm = await repos.identity.create(data, commit=commit) + return orm_to_user_identity(orm) + except IntegrityError as exc: + raise self._translate_db_error(exc, "user_identity") from exc + except SQLAlchemyError as exc: + raise self._translate_db_error(exc, "user_identity") from exc + except (NotFoundError, ConflictError, DependencyError): + # 契约层错误:mappers 翻译的 DependencyError 与本适配器抛出的 NotFoundError/ConflictError + # 直接放行,不二次翻译,保留原始异常链与错误码 + raise + except Exception as exc: + # 兜底:mappers 调用过程中可能抛出 TypeError 等非契约异常,翻译为 DependencyError + # 防止穿透至核心层(INV-7);保留异常链以便追踪 + await self._logger.error( + "user_identity_save_failed", + resource="user_identity", + error=str(exc), + ) + raise DependencyError("user_identity", Error(str(exc))) from exc async def getUserIdentity(self, identity_type: str, identity_value: str) -> UserIdentity | None: """按身份类型与身份值查询用户身份;不存在返回 None。 @@ -3342,28 +3386,29 @@ class ChannelPersistenceAdapter( Raises: DependencyError: 数据库故障。 """ - try: - orm = await self._repos.identity.get_by_type_and_value(identity_type, identity_value) - if orm is None: - return None - return orm_to_user_identity(orm) - except IntegrityError as exc: - raise self._translate_db_error(exc, "user_identity") from exc - except SQLAlchemyError as exc: - raise self._translate_db_error(exc, "user_identity") from exc - except (NotFoundError, ConflictError, DependencyError): - # 契约层错误:mappers 翻译的 DependencyError 与本适配器抛出的 NotFoundError/ConflictError - # 直接放行,不二次翻译,保留原始异常链与错误码 - raise - except Exception as exc: - # 兜底:mappers 调用过程中可能抛出 TypeError 等非契约异常,翻译为 DependencyError - # 防止穿透至核心层(INV-7);保留异常链以便追踪 - await self._logger.error( - "user_identity_get_failed", - resource="user_identity", - error=str(exc), - ) - raise DependencyError("user_identity", Error(str(exc))) from exc + async with self._session_scope(None) as (_, repos, _): + try: + orm = await repos.identity.get_by_type_and_value(identity_type, identity_value) + if orm is None: + return None + return orm_to_user_identity(orm) + except IntegrityError as exc: + raise self._translate_db_error(exc, "user_identity") from exc + except SQLAlchemyError as exc: + raise self._translate_db_error(exc, "user_identity") from exc + except (NotFoundError, ConflictError, DependencyError): + # 契约层错误:mappers 翻译的 DependencyError 与本适配器抛出的 NotFoundError/ConflictError + # 直接放行,不二次翻译,保留原始异常链与错误码 + raise + except Exception as exc: + # 兜底:mappers 调用过程中可能抛出 TypeError 等非契约异常,翻译为 DependencyError + # 防止穿透至核心层(INV-7);保留异常链以便追踪 + await self._logger.error( + "user_identity_get_failed", + resource="user_identity", + error=str(exc), + ) + raise DependencyError("user_identity", Error(str(exc))) from exc async def getUserIdentityByIdentityId(self, identity_id: str) -> UserIdentity | None: """按统一身份 ID 查询用户身份(P3 渐进式绑定);不存在返回 None。 @@ -3371,26 +3416,27 @@ class ChannelPersistenceAdapter( Raises: DependencyError: 数据库故障。 """ - try: - orm = await self._repos.identity.get_by_identity_id(identity_id) - if orm is None: - return None - return orm_to_user_identity(orm) - except IntegrityError as exc: - raise self._translate_db_error(exc, "user_identity") from exc - except SQLAlchemyError as exc: - raise self._translate_db_error(exc, "user_identity") from exc - except (NotFoundError, ConflictError, DependencyError): - # 契约层错误:直接放行,保留原始异常链与错误码 - raise - except Exception as exc: - # 兜底:防止非契约异常穿透至核心层(INV-7);保留异常链以便追踪 - await self._logger.error( - "user_identity_get_by_identity_id_failed", - resource="user_identity", - error=str(exc), - ) - raise DependencyError("user_identity", Error(str(exc))) from exc + async with self._session_scope(None) as (_, repos, _): + try: + orm = await repos.identity.get_by_identity_id(identity_id) + if orm is None: + return None + return orm_to_user_identity(orm) + except IntegrityError as exc: + raise self._translate_db_error(exc, "user_identity") from exc + except SQLAlchemyError as exc: + raise self._translate_db_error(exc, "user_identity") from exc + except (NotFoundError, ConflictError, DependencyError): + # 契约层错误:直接放行,保留原始异常链与错误码 + raise + except Exception as exc: + # 兜底:防止非契约异常穿透至核心层(INV-7);保留异常链以便追踪 + await self._logger.error( + "user_identity_get_by_identity_id_failed", + resource="user_identity", + error=str(exc), + ) + raise DependencyError("user_identity", Error(str(exc))) from exc async def listUserIdentitiesByUserId(self, user_id: str) -> list[UserIdentity]: """按系统用户 ID 列出所有关联的用户身份(跨渠道身份合并)。 @@ -3398,22 +3444,23 @@ class ChannelPersistenceAdapter( Raises: DependencyError: 数据库故障。 """ - try: - orms = await self._repos.identity.list_by_user_id(int(user_id)) - return [orm_to_user_identity(orm) for orm in orms] - except IntegrityError as exc: - raise self._translate_db_error(exc, "user_identity") from exc - except SQLAlchemyError as exc: - raise self._translate_db_error(exc, "user_identity") from exc - except (NotFoundError, ConflictError, DependencyError): - raise - except Exception as exc: - await self._logger.error( - "user_identity_list_by_user_id_failed", - resource="user_identity", - error=str(exc), - ) - raise DependencyError("user_identity", Error(str(exc))) from exc + async with self._session_scope(None) as (_, repos, _): + try: + orms = await repos.identity.list_by_user_id(int(user_id)) + return [orm_to_user_identity(orm) for orm in orms] + except IntegrityError as exc: + raise self._translate_db_error(exc, "user_identity") from exc + except SQLAlchemyError as exc: + raise self._translate_db_error(exc, "user_identity") from exc + except (NotFoundError, ConflictError, DependencyError): + raise + except Exception as exc: + await self._logger.error( + "user_identity_list_by_user_id_failed", + resource="user_identity", + error=str(exc), + ) + raise DependencyError("user_identity", Error(str(exc))) from exc async def updateUserIdentity( self, @@ -3442,41 +3489,41 @@ class ChannelPersistenceAdapter( ConflictError: 乐观锁版本不匹配。 DependencyError: 数据库故障。 """ - commit = self._should_commit(tx) - # 聚合根已递增 version,旧版本号 = 当前 version - 1 - expected_version = identity.version - 1 - updates: dict[str, Any] = { - "user_id": int(identity.user_id) if identity.user_id else None, - "identity_type": identity.identity_type, - "pending_review": identity.pending_review, - } - if operator_id is not None: - updates["updated_by"] = operator_id - try: - orm = await self._repos.identity.update_with_optimistic_lock( - identity_id=identity.identity_id, - expected_version=expected_version, - updates=updates, - commit=commit, - ) - if orm is None: - raise ConflictError("user_identity") - return orm_to_user_identity(orm) - except IntegrityError as exc: - raise self._translate_db_error(exc, "user_identity") from exc - except SQLAlchemyError as exc: - raise self._translate_db_error(exc, "user_identity") from exc - except (NotFoundError, ConflictError, DependencyError): - # 契约层错误:直接放行,保留原始异常链与错误码 - raise - except Exception as exc: - # 兜底:防止非契约异常穿透至核心层(INV-7);保留异常链以便追踪 - await self._logger.error( - "user_identity_update_failed", - resource="user_identity", - error=str(exc), - ) - raise DependencyError("user_identity", Error(str(exc))) from exc + async with self._session_scope(tx) as (_, repos, commit): + # 聚合根已递增 version,旧版本号 = 当前 version - 1 + expected_version = identity.version - 1 + updates: dict[str, Any] = { + "user_id": int(identity.user_id) if identity.user_id else None, + "identity_type": identity.identity_type, + "pending_review": identity.pending_review, + } + if operator_id is not None: + updates["updated_by"] = operator_id + try: + orm = await repos.identity.update_with_optimistic_lock( + identity_id=identity.identity_id, + expected_version=expected_version, + updates=updates, + commit=commit, + ) + if orm is None: + raise ConflictError("user_identity") + return orm_to_user_identity(orm) + except IntegrityError as exc: + raise self._translate_db_error(exc, "user_identity") from exc + except SQLAlchemyError as exc: + raise self._translate_db_error(exc, "user_identity") from exc + except (NotFoundError, ConflictError, DependencyError): + # 契约层错误:直接放行,保留原始异常链与错误码 + raise + except Exception as exc: + # 兜底:防止非契约异常穿透至核心层(INV-7);保留异常链以便追踪 + await self._logger.error( + "user_identity_update_failed", + resource="user_identity", + error=str(exc), + ) + raise DependencyError("user_identity", Error(str(exc))) from exc async def updateUserIdentityBindings( self, @@ -3499,36 +3546,36 @@ class ChannelPersistenceAdapter( ConflictError: 乐观锁版本不匹配(并发修改)。 DependencyError: 数据库故障。 """ - commit = self._should_commit(tx) - try: - orm = await self._repos.identity.get_by_identity_id(identity_id) - if orm is None: - raise NotFoundError("user_identity", identity_id) - rowcount = await self._repos.identity.update_bindings( - orm.id, - channel_bindings, - merged_from, - expected_version, - updated_by=operator_id, - commit=commit, - ) - if rowcount == 0: - raise ConflictError("user_identity") - except IntegrityError as exc: - raise self._translate_db_error(exc, "user_identity") from exc - except SQLAlchemyError as exc: - raise self._translate_db_error(exc, "user_identity") from exc - except (NotFoundError, ConflictError, DependencyError): - # 契约层错误:直接放行,保留原始异常链与错误码 - raise - except Exception as exc: - # 兜底:防止非契约异常穿透至核心层(INV-7);保留异常链以便追踪 - await self._logger.error( - "user_identity_update_bindings_failed", - resource="user_identity", - error=str(exc), - ) - raise DependencyError("user_identity", Error(str(exc))) from exc + async with self._session_scope(tx) as (_, repos, commit): + try: + orm = await repos.identity.get_by_identity_id(identity_id) + if orm is None: + raise NotFoundError("user_identity", identity_id) + rowcount = await repos.identity.update_bindings( + orm.id, + channel_bindings, + merged_from, + expected_version, + updated_by=operator_id, + commit=commit, + ) + if rowcount == 0: + raise ConflictError("user_identity") + except IntegrityError as exc: + raise self._translate_db_error(exc, "user_identity") from exc + except SQLAlchemyError as exc: + raise self._translate_db_error(exc, "user_identity") from exc + except (NotFoundError, ConflictError, DependencyError): + # 契约层错误:直接放行,保留原始异常链与错误码 + raise + except Exception as exc: + # 兜底:防止非契约异常穿透至核心层(INV-7);保留异常链以便追踪 + await self._logger.error( + "user_identity_update_bindings_failed", + resource="user_identity", + error=str(exc), + ) + raise DependencyError("user_identity", Error(str(exc))) from exc # ═══════════════════════════════════════════════════════════════════════════ # 幂等记录(FR-19) @@ -3540,22 +3587,23 @@ class ChannelPersistenceAdapter( Raises: DependencyError: 数据库故障。 """ - try: - orm = await self._repos.idempotency.get_by_key(idempotency_key) - if orm is None: - return None - return IdempotencyRecord( - record_id=orm.id, - idempotency_key=orm.idempotency_key, - operation=orm.operation, - status=orm.status, - response_body=orm.response_body, - in_progress_started_at=orm.in_progress_started_at, - ) - except IntegrityError as exc: - raise self._translate_db_error(exc, "channel_idempotency") from exc - except SQLAlchemyError as exc: - raise self._translate_db_error(exc, "channel_idempotency") from exc + async with self._session_scope(None) as (_, repos, _): + try: + orm = await repos.idempotency.get_by_key(idempotency_key) + if orm is None: + return None + return IdempotencyRecord( + record_id=orm.id, + idempotency_key=orm.idempotency_key, + operation=orm.operation, + status=orm.status, + response_body=orm.response_body, + in_progress_started_at=orm.in_progress_started_at, + ) + except IntegrityError as exc: + raise self._translate_db_error(exc, "channel_idempotency") from exc + except SQLAlchemyError as exc: + raise self._translate_db_error(exc, "channel_idempotency") from exc async def createIdempotencyRecord( self, @@ -3569,34 +3617,35 @@ class ChannelPersistenceAdapter( IdempotencyConflictError: 幂等键已存在(重复请求)。 DependencyError: 数据库故障。 """ - try: - orm = await self._repos.idempotency.create( - { - "idempotency_key": idempotency_key, - "operation": operation, - "created_by": created_by, - } - ) - return IdempotencyRecord( - record_id=orm.id, - idempotency_key=orm.idempotency_key, - operation=orm.operation, - status=orm.status, - response_body=orm.response_body, - in_progress_started_at=orm.in_progress_started_at, - ) - except IntegrityError as exc: - # P2-26: 特化翻译为 IdempotencyConflictError(而非通用 ConflictError), - # 携带 idempotency_key 供调用方关联已存在记录。existing_resource_type - # 使用 operation(与已存在记录的 operation 一致,因 idempotency_key - # 绑定特定操作);existing_resource_id 留空,调用方可按需查表补充。 - raise IdempotencyConflictError( - idempotency_key=idempotency_key, - existing_resource_type=operation, - existing_resource_id="", - ) from exc - except SQLAlchemyError as exc: - raise self._translate_db_error(exc, "channel_idempotency") from exc + async with self._session_scope(None) as (_, repos, _): + try: + orm = await repos.idempotency.create( + { + "idempotency_key": idempotency_key, + "operation": operation, + "created_by": created_by, + } + ) + return IdempotencyRecord( + record_id=orm.id, + idempotency_key=orm.idempotency_key, + operation=orm.operation, + status=orm.status, + response_body=orm.response_body, + in_progress_started_at=orm.in_progress_started_at, + ) + except IntegrityError as exc: + # P2-26: 特化翻译为 IdempotencyConflictError(而非通用 ConflictError), + # 携带 idempotency_key 供调用方关联已存在记录。existing_resource_type + # 使用 operation(与已存在记录的 operation 一致,因 idempotency_key + # 绑定特定操作);existing_resource_id 留空,调用方可按需查表补充。 + raise IdempotencyConflictError( + idempotency_key=idempotency_key, + existing_resource_type=operation, + existing_resource_id="", + ) from exc + except SQLAlchemyError as exc: + raise self._translate_db_error(exc, "channel_idempotency") from exc async def updateIdempotencyStatus( self, @@ -3610,18 +3659,19 @@ class ChannelPersistenceAdapter( Raises: DependencyError: 数据库故障。 """ - try: - rowcount = await self._repos.idempotency.update_status( - record_id, - status, - response_body=response_body, - updated_by=updated_by, - ) - return rowcount > 0 - except IntegrityError as exc: - raise self._translate_db_error(exc, "channel_idempotency") from exc - except SQLAlchemyError as exc: - raise self._translate_db_error(exc, "channel_idempotency") from exc + async with self._session_scope(None) as (_, repos, _): + try: + rowcount = await repos.idempotency.update_status( + record_id, + status, + response_body=response_body, + updated_by=updated_by, + ) + return rowcount > 0 + except IntegrityError as exc: + raise self._translate_db_error(exc, "channel_idempotency") from exc + except SQLAlchemyError as exc: + raise self._translate_db_error(exc, "channel_idempotency") from exc async def deleteIdempotencyRecord(self, idempotency_key: str) -> bool: """按幂等键物理删除记录,允许 failed 记录重试。 @@ -3629,13 +3679,14 @@ class ChannelPersistenceAdapter( Raises: DependencyError: 数据库故障。 """ - try: - rowcount = await self._repos.idempotency.delete_by_key(idempotency_key) - return rowcount > 0 - except IntegrityError as exc: - raise self._translate_db_error(exc, "channel_idempotency") from exc - except SQLAlchemyError as exc: - raise self._translate_db_error(exc, "channel_idempotency") from exc + async with self._session_scope(None) as (_, repos, _): + try: + rowcount = await repos.idempotency.delete_by_key(idempotency_key) + return rowcount > 0 + except IntegrityError as exc: + raise self._translate_db_error(exc, "channel_idempotency") from exc + except SQLAlchemyError as exc: + raise self._translate_db_error(exc, "channel_idempotency") from exc async def deleteExpiredRecords(self, before: datetime) -> int: """物理删除已过期的幂等记录(定时清理任务)。 @@ -3643,12 +3694,13 @@ class ChannelPersistenceAdapter( Raises: DependencyError: 数据库故障。 """ - try: - return await self._repos.idempotency.delete_expired(before) - except IntegrityError as exc: - raise self._translate_db_error(exc, "channel_idempotency") from exc - except SQLAlchemyError as exc: - raise self._translate_db_error(exc, "channel_idempotency") from exc + async with self._session_scope(None) as (_, repos, _): + try: + return await repos.idempotency.delete_expired(before) + except IntegrityError as exc: + raise self._translate_db_error(exc, "channel_idempotency") from exc + except SQLAlchemyError as exc: + raise self._translate_db_error(exc, "channel_idempotency") from exc # ═══════════════════════════════════════════════════════════════════════════ # 健康检查与诊断(FR-35) @@ -3664,16 +3716,17 @@ class ChannelPersistenceAdapter( 日志(含异常摘要)再返回 ``False``,供 ``HealthAggregator`` 判断 数据库状态,根因可通过日志追溯。 """ - try: - await self._db.execute(text("SELECT 1")) - return True - except SQLAlchemyError as exc: - await self._logger.warn( - "db_ping_failed", - resource="db", - error=str(exc), - ) - return False + async with self._session_scope(None) as (db, _, _): + try: + await db.execute(text("SELECT 1")) + return True + except SQLAlchemyError as exc: + await self._logger.warn( + "db_ping_failed", + resource="db", + error=str(exc), + ) + return False async def getConnectionPoolStatus(self) -> ConnectionPoolStatus: """获取数据库连接池状态。 @@ -3684,18 +3737,19 @@ class ChannelPersistenceAdapter( Raises: DependencyError: 数据库故障。 """ - engine = self._db.bind - if engine is None: - return ConnectionPoolStatus(available=False, reason="engine not bound") - pool = engine.pool - return ConnectionPoolStatus( - available=True, - size=pool.size(), - checked_in=pool.checkedin(), - checked_out=pool.checkedout(), - overflow=pool.overflow(), - status=pool.status(), - ) + async with self._session_scope(None) as (db, _, _): + engine = db.bind + if engine is None: + return ConnectionPoolStatus(available=False, reason="engine not bound") + pool = engine.pool + return ConnectionPoolStatus( + available=True, + size=pool.size(), + checked_in=pool.checkedin(), + checked_out=pool.checkedout(), + overflow=pool.overflow(), + status=pool.status(), + ) # ═══════════════════════════════════════════════════════════════════════════ # 聚合视图域 analytics 子域深度分析(ANL-01/02/04/05/06/07) @@ -3718,104 +3772,105 @@ class ChannelPersistenceAdapter( Raises: DependencyError: 数据库故障。 """ - try: - # DB 时间列为 naive UTC,剥离 DTO 入参的 tzinfo(见 _to_naive_utc 说明)。 - start_naive = _to_naive_utc(start_time) - end_naive = _to_naive_utc(end_time) - # 总数查询 - total_stmt = select(func.count(MessageORM.id)).where( - MessageORM.created_at >= start_naive, - MessageORM.created_at < end_naive, - ) - if channel_type is not None: - total_stmt = total_stmt.join( - ConversationORM, - ConversationORM.id == MessageORM.conversation_id, - ).where(ConversationORM.channel_type == channel_type) - total = int(await self._db.scalar(total_stmt) or 0) - - # 时间序列 - bucket_expr = func.date_trunc(granularity, MessageORM.created_at) - ts_stmt = ( - select(bucket_expr, func.count(MessageORM.id)) - .where( + async with self._session_scope(None) as (db, _, _): + try: + # DB 时间列为 naive UTC,剥离 DTO 入参的 tzinfo(见 _to_naive_utc 说明)。 + start_naive = _to_naive_utc(start_time) + end_naive = _to_naive_utc(end_time) + # 总数查询 + total_stmt = select(func.count(MessageORM.id)).where( MessageORM.created_at >= start_naive, MessageORM.created_at < end_naive, ) - .group_by(bucket_expr) - .order_by(bucket_expr) - ) - if channel_type is not None: - ts_stmt = ts_stmt.join( - ConversationORM, - ConversationORM.id == MessageORM.conversation_id, - ).where(ConversationORM.channel_type == channel_type) - ts_result = await self._db.execute(ts_stmt) - timeseries = tuple( - TimeSeriesPoint( - timestamp=format_utc_datetime(bucket) or "", - value=int(count), - ) - for bucket, count in ts_result.all() - ) + if channel_type is not None: + total_stmt = total_stmt.join( + ConversationORM, + ConversationORM.id == MessageORM.conversation_id, + ).where(ConversationORM.channel_type == channel_type) + total = int(await db.scalar(total_stmt) or 0) - # 按渠道、角色、投递状态联合分组(单次查询) - combined_stmt = ( - select( - ConversationORM.channel_type, - MessageORM.role, - MessageORM.delivery_status, - func.count(MessageORM.id).label("count"), + # 时间序列 + bucket_expr = func.date_trunc(granularity, MessageORM.created_at) + ts_stmt = ( + select(bucket_expr, func.count(MessageORM.id)) + .where( + MessageORM.created_at >= start_naive, + MessageORM.created_at < end_naive, + ) + .group_by(bucket_expr) + .order_by(bucket_expr) ) - .join( - ConversationORM, - ConversationORM.id == MessageORM.conversation_id, + if channel_type is not None: + ts_stmt = ts_stmt.join( + ConversationORM, + ConversationORM.id == MessageORM.conversation_id, + ).where(ConversationORM.channel_type == channel_type) + ts_result = await db.execute(ts_stmt) + timeseries = tuple( + TimeSeriesPoint( + timestamp=format_utc_datetime(bucket) or "", + value=int(count), + ) + for bucket, count in ts_result.all() ) - .where( - MessageORM.created_at >= start_naive, - MessageORM.created_at < end_naive, - ) - .group_by( - ConversationORM.channel_type, - MessageORM.role, - MessageORM.delivery_status, - ) - ) - if channel_type is not None: - combined_stmt = combined_stmt.where(ConversationORM.channel_type == channel_type) - combined_result = await self._db.execute(combined_stmt) - by_channel: dict[str, int] = {} - by_role: dict[str, int] = {} - by_delivery_status: dict[str, int] = {} - for ch, role, status, count in combined_result.all(): - cnt = int(count) - if ch is not None: - by_channel[ch] = by_channel.get(ch, 0) + cnt - if role is not None: - by_role[role] = by_role.get(role, 0) + cnt - if status is not None: - by_delivery_status[status] = by_delivery_status.get(status, 0) + cnt - return MessageAnalytics( - total=total, - timeseries=timeseries, - by_channel=by_channel, - by_role=by_role, - by_delivery_status=by_delivery_status, - ) - except IntegrityError as exc: - raise self._translate_db_error(exc, "message") from exc - except SQLAlchemyError as exc: - raise self._translate_db_error(exc, "message") from exc - except (NotFoundError, ConflictError, DependencyError): - raise - except Exception as exc: - await self._logger.error( - "message_analyze_failed", - resource="message", - error=str(exc), - ) - raise DependencyError("message", Error(str(exc))) from exc + # 按渠道、角色、投递状态联合分组(单次查询) + combined_stmt = ( + select( + ConversationORM.channel_type, + MessageORM.role, + MessageORM.delivery_status, + func.count(MessageORM.id).label("count"), + ) + .join( + ConversationORM, + ConversationORM.id == MessageORM.conversation_id, + ) + .where( + MessageORM.created_at >= start_naive, + MessageORM.created_at < end_naive, + ) + .group_by( + ConversationORM.channel_type, + MessageORM.role, + MessageORM.delivery_status, + ) + ) + if channel_type is not None: + combined_stmt = combined_stmt.where(ConversationORM.channel_type == channel_type) + combined_result = await db.execute(combined_stmt) + by_channel: dict[str, int] = {} + by_role: dict[str, int] = {} + by_delivery_status: dict[str, int] = {} + for ch, role, status, count in combined_result.all(): + cnt = int(count) + if ch is not None: + by_channel[ch] = by_channel.get(ch, 0) + cnt + if role is not None: + by_role[role] = by_role.get(role, 0) + cnt + if status is not None: + by_delivery_status[status] = by_delivery_status.get(status, 0) + cnt + + return MessageAnalytics( + total=total, + timeseries=timeseries, + by_channel=by_channel, + by_role=by_role, + by_delivery_status=by_delivery_status, + ) + except IntegrityError as exc: + raise self._translate_db_error(exc, "message") from exc + except SQLAlchemyError as exc: + raise self._translate_db_error(exc, "message") from exc + except (NotFoundError, ConflictError, DependencyError): + raise + except Exception as exc: + await self._logger.error( + "message_analyze_failed", + resource="message", + error=str(exc), + ) + raise DependencyError("message", Error(str(exc))) from exc async def getMessageDistribution( self, @@ -3832,61 +3887,62 @@ class ChannelPersistenceAdapter( Raises: DependencyError: 数据库故障。 """ - try: - # DB 时间列为 naive UTC,剥离 DTO 入参的 tzinfo(见 _to_naive_utc 说明)。 - start_naive = _to_naive_utc(start_time) - end_naive = _to_naive_utc(end_time) - # 总数查询 - total_stmt = select(func.count(MessageORM.id)).where( - MessageORM.created_at >= start_naive, - MessageORM.created_at < end_naive, - ) - if channel_type is not None: - total_stmt = total_stmt.join( - ConversationORM, - ConversationORM.id == MessageORM.conversation_id, - ).where(ConversationORM.channel_type == channel_type) - total = int(await self._db.scalar(total_stmt) or 0) - - # 按消息类型分组 - type_stmt = ( - select(MessageORM.message_type, func.count(MessageORM.id)) - .where( + async with self._session_scope(None) as (db, _, _): + try: + # DB 时间列为 naive UTC,剥离 DTO 入参的 tzinfo(见 _to_naive_utc 说明)。 + start_naive = _to_naive_utc(start_time) + end_naive = _to_naive_utc(end_time) + # 总数查询 + total_stmt = select(func.count(MessageORM.id)).where( MessageORM.created_at >= start_naive, MessageORM.created_at < end_naive, ) - .group_by(MessageORM.message_type) - ) - if channel_type is not None: - type_stmt = type_stmt.join( - ConversationORM, - ConversationORM.id == MessageORM.conversation_id, - ).where(ConversationORM.channel_type == channel_type) - type_result = await self._db.execute(type_stmt) - by_type: dict[str, int] = {} - for msg_type, count in type_result.all(): - if msg_type is not None: - by_type[msg_type] = int(count) + if channel_type is not None: + total_stmt = total_stmt.join( + ConversationORM, + ConversationORM.id == MessageORM.conversation_id, + ).where(ConversationORM.channel_type == channel_type) + total = int(await db.scalar(total_stmt) or 0) - by_type_percent = {k: (v / total * 100) if total > 0 else 0.0 for k, v in by_type.items()} - return MessageDistribution( - total=total, - by_type=by_type, - by_type_percent=by_type_percent, - ) - except IntegrityError as exc: - raise self._translate_db_error(exc, "message") from exc - except SQLAlchemyError as exc: - raise self._translate_db_error(exc, "message") from exc - except (NotFoundError, ConflictError, DependencyError): - raise - except Exception as exc: - await self._logger.error( - "message_get_distribution_failed", - resource="message", - error=str(exc), - ) - raise DependencyError("message", Error(str(exc))) from exc + # 按消息类型分组 + type_stmt = ( + select(MessageORM.message_type, func.count(MessageORM.id)) + .where( + MessageORM.created_at >= start_naive, + MessageORM.created_at < end_naive, + ) + .group_by(MessageORM.message_type) + ) + if channel_type is not None: + type_stmt = type_stmt.join( + ConversationORM, + ConversationORM.id == MessageORM.conversation_id, + ).where(ConversationORM.channel_type == channel_type) + type_result = await db.execute(type_stmt) + by_type: dict[str, int] = {} + for msg_type, count in type_result.all(): + if msg_type is not None: + by_type[msg_type] = int(count) + + by_type_percent = {k: (v / total * 100) if total > 0 else 0.0 for k, v in by_type.items()} + return MessageDistribution( + total=total, + by_type=by_type, + by_type_percent=by_type_percent, + ) + except IntegrityError as exc: + raise self._translate_db_error(exc, "message") from exc + except SQLAlchemyError as exc: + raise self._translate_db_error(exc, "message") from exc + except (NotFoundError, ConflictError, DependencyError): + raise + except Exception as exc: + await self._logger.error( + "message_get_distribution_failed", + resource="message", + error=str(exc), + ) + raise DependencyError("message", Error(str(exc))) from exc async def analyzeSessions( self, @@ -3905,135 +3961,136 @@ class ChannelPersistenceAdapter( Raises: DependencyError: 数据库故障。 """ - try: - # DB 时间列为 naive UTC,剥离 DTO 入参的 tzinfo(见 _to_naive_utc 说明)。 - start_naive = _to_naive_utc(start_time) - end_naive = _to_naive_utc(end_time) - # 总数查询 - total_stmt = select(func.count(ChannelSessionORM.id)).where( - ChannelSessionORM.last_message_at >= start_naive, - ChannelSessionORM.last_message_at < end_naive, - ChannelSessionORM.is_deleted == 0, - ) - if channel_type is not None: - total_stmt = total_stmt.where(ChannelSessionORM.channel_type == channel_type) - total = int(await self._db.scalar(total_stmt) or 0) - - # 时间序列 - bucket_expr = func.date_trunc(granularity, ChannelSessionORM.last_message_at) - ts_stmt = ( - select(bucket_expr, func.count(ChannelSessionORM.id)) - .where( + async with self._session_scope(None) as (db, _, _): + try: + # DB 时间列为 naive UTC,剥离 DTO 入参的 tzinfo(见 _to_naive_utc 说明)。 + start_naive = _to_naive_utc(start_time) + end_naive = _to_naive_utc(end_time) + # 总数查询 + total_stmt = select(func.count(ChannelSessionORM.id)).where( ChannelSessionORM.last_message_at >= start_naive, ChannelSessionORM.last_message_at < end_naive, ChannelSessionORM.is_deleted == 0, ) - .group_by(bucket_expr) - .order_by(bucket_expr) - ) - if channel_type is not None: - ts_stmt = ts_stmt.where(ChannelSessionORM.channel_type == channel_type) - ts_result = await self._db.execute(ts_stmt) - timeseries = tuple( - TimeSeriesPoint( - timestamp=format_utc_datetime(bucket) or "", - value=int(count), - ) - for bucket, count in ts_result.all() - ) + if channel_type is not None: + total_stmt = total_stmt.where(ChannelSessionORM.channel_type == channel_type) + total = int(await db.scalar(total_stmt) or 0) - # 按渠道、临时性联合分组 - combined_stmt = ( - select( - ChannelSessionORM.channel_type, - ChannelSessionORM.is_temporary, - func.count(ChannelSessionORM.id), + # 时间序列 + bucket_expr = func.date_trunc(granularity, ChannelSessionORM.last_message_at) + ts_stmt = ( + select(bucket_expr, func.count(ChannelSessionORM.id)) + .where( + ChannelSessionORM.last_message_at >= start_naive, + ChannelSessionORM.last_message_at < end_naive, + ChannelSessionORM.is_deleted == 0, + ) + .group_by(bucket_expr) + .order_by(bucket_expr) ) - .where( - ChannelSessionORM.last_message_at >= start_naive, - ChannelSessionORM.last_message_at < end_naive, - ChannelSessionORM.is_deleted == 0, + if channel_type is not None: + ts_stmt = ts_stmt.where(ChannelSessionORM.channel_type == channel_type) + ts_result = await db.execute(ts_stmt) + timeseries = tuple( + TimeSeriesPoint( + timestamp=format_utc_datetime(bucket) or "", + value=int(count), + ) + for bucket, count in ts_result.all() ) - .group_by( - ChannelSessionORM.channel_type, - ChannelSessionORM.is_temporary, - ) - ) - if channel_type is not None: - combined_stmt = combined_stmt.where(ChannelSessionORM.channel_type == channel_type) - combined_result = await self._db.execute(combined_stmt) - by_channel: dict[str, int] = {} - by_is_temporary: dict[str, int] = {"true": 0, "false": 0} - for ch, is_tmp, count in combined_result.all(): - cnt = int(count) - if ch is not None: - by_channel[ch] = by_channel.get(ch, 0) + cnt - key = "true" if is_tmp else "false" - by_is_temporary[key] += cnt - # 按消息数分桶(子查询统计各 conversation 消息总数) - msg_count_subq = ( - select( - MessageORM.conversation_id.label("conv_id"), - func.count(MessageORM.id).label("msg_count"), + # 按渠道、临时性联合分组 + combined_stmt = ( + select( + ChannelSessionORM.channel_type, + ChannelSessionORM.is_temporary, + func.count(ChannelSessionORM.id), + ) + .where( + ChannelSessionORM.last_message_at >= start_naive, + ChannelSessionORM.last_message_at < end_naive, + ChannelSessionORM.is_deleted == 0, + ) + .group_by( + ChannelSessionORM.channel_type, + ChannelSessionORM.is_temporary, + ) ) - .group_by(MessageORM.conversation_id) - .subquery() - ) - mc_bucket = case( - (msg_count_subq.c.msg_count.is_(None), "0-10"), - (msg_count_subq.c.msg_count <= 10, "0-10"), - (msg_count_subq.c.msg_count <= 50, "11-50"), - (msg_count_subq.c.msg_count <= 100, "51-100"), - else_="100+", - ) - mc_stmt = ( - select(mc_bucket, func.count(ChannelSessionORM.id)) - .select_from(ChannelSessionORM) - .outerjoin( - msg_count_subq, - msg_count_subq.c.conv_id == ChannelSessionORM.conversation_id, - ) - .where( - ChannelSessionORM.last_message_at >= start_naive, - ChannelSessionORM.last_message_at < end_naive, - ChannelSessionORM.is_deleted == 0, - ) - .group_by(mc_bucket) - ) - if channel_type is not None: - mc_stmt = mc_stmt.where(ChannelSessionORM.channel_type == channel_type) - mc_result = await self._db.execute(mc_stmt) - by_message_count_bucket: dict[str, int] = { - "0-10": 0, - "11-50": 0, - "51-100": 0, - "100+": 0, - } - for bucket, count in mc_result.all(): - if bucket is not None and bucket in by_message_count_bucket: - by_message_count_bucket[bucket] = int(count) + if channel_type is not None: + combined_stmt = combined_stmt.where(ChannelSessionORM.channel_type == channel_type) + combined_result = await db.execute(combined_stmt) + by_channel: dict[str, int] = {} + by_is_temporary: dict[str, int] = {"true": 0, "false": 0} + for ch, is_tmp, count in combined_result.all(): + cnt = int(count) + if ch is not None: + by_channel[ch] = by_channel.get(ch, 0) + cnt + key = "true" if is_tmp else "false" + by_is_temporary[key] += cnt - return SessionAnalytics( - total=total, - timeseries=timeseries, - by_channel=by_channel, - by_is_temporary=by_is_temporary, - by_message_count_bucket=by_message_count_bucket, - ) - except IntegrityError as exc: - raise self._translate_db_error(exc, "channel_session") from exc - except SQLAlchemyError as exc: - raise self._translate_db_error(exc, "channel_session") from exc - except (NotFoundError, ConflictError, DependencyError): - raise - except Exception as exc: - await self._logger.error( - "channel_session_analyze_sessions_failed", - resource="channel_session", - error=str(exc), - ) - raise DependencyError("channel_session", Error(str(exc))) from exc + # 按消息数分桶(子查询统计各 conversation 消息总数) + msg_count_subq = ( + select( + MessageORM.conversation_id.label("conv_id"), + func.count(MessageORM.id).label("msg_count"), + ) + .group_by(MessageORM.conversation_id) + .subquery() + ) + mc_bucket = case( + (msg_count_subq.c.msg_count.is_(None), "0-10"), + (msg_count_subq.c.msg_count <= 10, "0-10"), + (msg_count_subq.c.msg_count <= 50, "11-50"), + (msg_count_subq.c.msg_count <= 100, "51-100"), + else_="100+", + ) + mc_stmt = ( + select(mc_bucket, func.count(ChannelSessionORM.id)) + .select_from(ChannelSessionORM) + .outerjoin( + msg_count_subq, + msg_count_subq.c.conv_id == ChannelSessionORM.conversation_id, + ) + .where( + ChannelSessionORM.last_message_at >= start_naive, + ChannelSessionORM.last_message_at < end_naive, + ChannelSessionORM.is_deleted == 0, + ) + .group_by(mc_bucket) + ) + if channel_type is not None: + mc_stmt = mc_stmt.where(ChannelSessionORM.channel_type == channel_type) + mc_result = await db.execute(mc_stmt) + by_message_count_bucket: dict[str, int] = { + "0-10": 0, + "11-50": 0, + "51-100": 0, + "100+": 0, + } + for bucket, count in mc_result.all(): + if bucket is not None and bucket in by_message_count_bucket: + by_message_count_bucket[bucket] = int(count) + + return SessionAnalytics( + total=total, + timeseries=timeseries, + by_channel=by_channel, + by_is_temporary=by_is_temporary, + by_message_count_bucket=by_message_count_bucket, + ) + except IntegrityError as exc: + raise self._translate_db_error(exc, "channel_session") from exc + except SQLAlchemyError as exc: + raise self._translate_db_error(exc, "channel_session") from exc + except (NotFoundError, ConflictError, DependencyError): + raise + except Exception as exc: + await self._logger.error( + "channel_session_analyze_sessions_failed", + resource="channel_session", + error=str(exc), + ) + raise DependencyError("channel_session", Error(str(exc))) from exc async def analyzeDelivery( self, @@ -4051,104 +4108,105 @@ class ChannelPersistenceAdapter( Raises: DependencyError: 数据库故障。 """ - try: - # DB 时间列为 naive UTC,剥离 DTO 入参的 tzinfo(见 _to_naive_utc 说明)。 - start_naive = _to_naive_utc(start_time) - end_naive = _to_naive_utc(end_time) - # 总数、成功数、失败数、平均延迟(条件聚合单次查询) - agg_stmt = select( - func.count(ChannelOutboxEntryORM.id).label("total"), - func.sum( - case( - (ChannelOutboxEntryORM.status == "sent", 1), - else_=0, - ) - ).label("success"), - func.sum( - case( - ( - ChannelOutboxEntryORM.status.in_(["failed", "dead"]), - 1, - ), - else_=0, - ) - ).label("failure"), - func.avg(ChannelOutboxEntryORM.latency_ms).label("avg_latency"), - ).where( - ChannelOutboxEntryORM.created_at >= start_naive, - ChannelOutboxEntryORM.created_at < end_naive, - ChannelOutboxEntryORM.is_deleted == 0, - ) - if channel_type is not None: - agg_stmt = agg_stmt.join( - ChannelAccountORM, - ChannelOutboxEntryORM.account_id == ChannelAccountORM.id, - ).where(ChannelAccountORM.channel_type == channel_type) - agg_row = (await self._db.execute(agg_stmt)).one() - total = int(agg_row.total or 0) - success_count = int(agg_row.success or 0) - failure_count = int(agg_row.failure or 0) - avg_latency = float(agg_row.avg_latency) if agg_row.avg_latency is not None else None - success_rate = (success_count / total * 100) if total > 0 else None - failure_rate = (failure_count / total * 100) if total > 0 else None - - # 重试分布分桶 - retry_bucket = case( - (ChannelOutboxEntryORM.retry_count == 0, "0"), - (ChannelOutboxEntryORM.retry_count == 1, "1"), - (ChannelOutboxEntryORM.retry_count == 2, "2"), - ( - ChannelOutboxEntryORM.retry_count >= ChannelOutboxEntryORM.max_retry, - "max_reached", - ), - else_="3+", - ) - retry_stmt = ( - select(retry_bucket, func.count(ChannelOutboxEntryORM.id)) - .where( + async with self._session_scope(None) as (db, _, _): + try: + # DB 时间列为 naive UTC,剥离 DTO 入参的 tzinfo(见 _to_naive_utc 说明)。 + start_naive = _to_naive_utc(start_time) + end_naive = _to_naive_utc(end_time) + # 总数、成功数、失败数、平均延迟(条件聚合单次查询) + agg_stmt = select( + func.count(ChannelOutboxEntryORM.id).label("total"), + func.sum( + case( + (ChannelOutboxEntryORM.status == "sent", 1), + else_=0, + ) + ).label("success"), + func.sum( + case( + ( + ChannelOutboxEntryORM.status.in_(["failed", "dead"]), + 1, + ), + else_=0, + ) + ).label("failure"), + func.avg(ChannelOutboxEntryORM.latency_ms).label("avg_latency"), + ).where( ChannelOutboxEntryORM.created_at >= start_naive, ChannelOutboxEntryORM.created_at < end_naive, ChannelOutboxEntryORM.is_deleted == 0, ) - .group_by(retry_bucket) - ) - if channel_type is not None: - retry_stmt = retry_stmt.join( - ChannelAccountORM, - ChannelOutboxEntryORM.account_id == ChannelAccountORM.id, - ).where(ChannelAccountORM.channel_type == channel_type) - retry_result = await self._db.execute(retry_stmt) - retry_distribution: dict[str, int] = { - "0": 0, - "1": 0, - "2": 0, - "3+": 0, - "max_reached": 0, - } - for bucket, count in retry_result.all(): - if bucket is not None and bucket in retry_distribution: - retry_distribution[bucket] = int(count) + if channel_type is not None: + agg_stmt = agg_stmt.join( + ChannelAccountORM, + ChannelOutboxEntryORM.account_id == ChannelAccountORM.id, + ).where(ChannelAccountORM.channel_type == channel_type) + agg_row = (await db.execute(agg_stmt)).one() + total = int(agg_row.total or 0) + success_count = int(agg_row.success or 0) + failure_count = int(agg_row.failure or 0) + avg_latency = float(agg_row.avg_latency) if agg_row.avg_latency is not None else None + success_rate = (success_count / total * 100) if total > 0 else None + failure_rate = (failure_count / total * 100) if total > 0 else None - return DeliveryAnalytics( - total=total, - success_rate=success_rate, - failure_rate=failure_rate, - retry_distribution=retry_distribution, - avg_latency_ms=avg_latency, - ) - except IntegrityError as exc: - raise self._translate_db_error(exc, "channel_outbox") from exc - except SQLAlchemyError as exc: - raise self._translate_db_error(exc, "channel_outbox") from exc - except (NotFoundError, ConflictError, DependencyError): - raise - except Exception as exc: - await self._logger.error( - "channel_outbox_analyze_delivery_failed", - resource="channel_outbox", - error=str(exc), - ) - raise DependencyError("channel_outbox", Error(str(exc))) from exc + # 重试分布分桶 + retry_bucket = case( + (ChannelOutboxEntryORM.retry_count == 0, "0"), + (ChannelOutboxEntryORM.retry_count == 1, "1"), + (ChannelOutboxEntryORM.retry_count == 2, "2"), + ( + ChannelOutboxEntryORM.retry_count >= ChannelOutboxEntryORM.max_retry, + "max_reached", + ), + else_="3+", + ) + retry_stmt = ( + select(retry_bucket, func.count(ChannelOutboxEntryORM.id)) + .where( + ChannelOutboxEntryORM.created_at >= start_naive, + ChannelOutboxEntryORM.created_at < end_naive, + ChannelOutboxEntryORM.is_deleted == 0, + ) + .group_by(retry_bucket) + ) + if channel_type is not None: + retry_stmt = retry_stmt.join( + ChannelAccountORM, + ChannelOutboxEntryORM.account_id == ChannelAccountORM.id, + ).where(ChannelAccountORM.channel_type == channel_type) + retry_result = await db.execute(retry_stmt) + retry_distribution: dict[str, int] = { + "0": 0, + "1": 0, + "2": 0, + "3+": 0, + "max_reached": 0, + } + for bucket, count in retry_result.all(): + if bucket is not None and bucket in retry_distribution: + retry_distribution[bucket] = int(count) + + return DeliveryAnalytics( + total=total, + success_rate=success_rate, + failure_rate=failure_rate, + retry_distribution=retry_distribution, + avg_latency_ms=avg_latency, + ) + except IntegrityError as exc: + raise self._translate_db_error(exc, "channel_outbox") from exc + except SQLAlchemyError as exc: + raise self._translate_db_error(exc, "channel_outbox") from exc + except (NotFoundError, ConflictError, DependencyError): + raise + except Exception as exc: + await self._logger.error( + "channel_outbox_analyze_delivery_failed", + resource="channel_outbox", + error=str(exc), + ) + raise DependencyError("channel_outbox", Error(str(exc))) from exc async def getDeliveryLatencyDistribution( self, @@ -4165,87 +4223,88 @@ class ChannelPersistenceAdapter( Raises: DependencyError: 数据库故障。 """ - try: - # DB 时间列为 naive UTC,剥离 DTO 入参的 tzinfo(见 _to_naive_utc 说明)。 - start_naive = _to_naive_utc(start_time) - end_naive = _to_naive_utc(end_time) - # 分位数与最大值 - pct_stmt = select( - func.percentile_cont(0.5).within_group(ChannelOutboxEntryORM.latency_ms).label("p50"), - func.percentile_cont(0.9).within_group(ChannelOutboxEntryORM.latency_ms).label("p90"), - func.percentile_cont(0.99).within_group(ChannelOutboxEntryORM.latency_ms).label("p99"), - func.max(ChannelOutboxEntryORM.latency_ms).label("max"), - ).where( - ChannelOutboxEntryORM.created_at >= start_naive, - ChannelOutboxEntryORM.created_at < end_naive, - ChannelOutboxEntryORM.is_deleted == 0, - ) - if channel_type is not None: - pct_stmt = pct_stmt.join( - ChannelAccountORM, - ChannelOutboxEntryORM.account_id == ChannelAccountORM.id, - ).where(ChannelAccountORM.channel_type == channel_type) - pct_row = (await self._db.execute(pct_stmt)).one() - p50 = float(pct_row.p50) if pct_row.p50 is not None else None - p90 = float(pct_row.p90) if pct_row.p90 is not None else None - p99 = float(pct_row.p99) if pct_row.p99 is not None else None - max_ms = float(pct_row.max) if pct_row.max is not None else None - - # 直方图分桶 - latency_bucket = case( - (ChannelOutboxEntryORM.latency_ms <= 100, "0-100ms"), - (ChannelOutboxEntryORM.latency_ms <= 500, "100-500ms"), - (ChannelOutboxEntryORM.latency_ms <= 1000, "500ms-1s"), - (ChannelOutboxEntryORM.latency_ms <= 5000, "1-5s"), - else_="5s+", - ) - hist_stmt = ( - select(latency_bucket, func.count(ChannelOutboxEntryORM.id)) - .where( + async with self._session_scope(None) as (db, _, _): + try: + # DB 时间列为 naive UTC,剥离 DTO 入参的 tzinfo(见 _to_naive_utc 说明)。 + start_naive = _to_naive_utc(start_time) + end_naive = _to_naive_utc(end_time) + # 分位数与最大值 + pct_stmt = select( + func.percentile_cont(0.5).within_group(ChannelOutboxEntryORM.latency_ms).label("p50"), + func.percentile_cont(0.9).within_group(ChannelOutboxEntryORM.latency_ms).label("p90"), + func.percentile_cont(0.99).within_group(ChannelOutboxEntryORM.latency_ms).label("p99"), + func.max(ChannelOutboxEntryORM.latency_ms).label("max"), + ).where( ChannelOutboxEntryORM.created_at >= start_naive, ChannelOutboxEntryORM.created_at < end_naive, ChannelOutboxEntryORM.is_deleted == 0, - ChannelOutboxEntryORM.latency_ms.isnot(None), ) - .group_by(latency_bucket) - ) - if channel_type is not None: - hist_stmt = hist_stmt.join( - ChannelAccountORM, - ChannelOutboxEntryORM.account_id == ChannelAccountORM.id, - ).where(ChannelAccountORM.channel_type == channel_type) - hist_result = await self._db.execute(hist_stmt) - histogram: dict[str, int] = { - "0-100ms": 0, - "100-500ms": 0, - "500ms-1s": 0, - "1-5s": 0, - "5s+": 0, - } - for bucket, count in hist_result.all(): - if bucket is not None and bucket in histogram: - histogram[bucket] = int(count) + if channel_type is not None: + pct_stmt = pct_stmt.join( + ChannelAccountORM, + ChannelOutboxEntryORM.account_id == ChannelAccountORM.id, + ).where(ChannelAccountORM.channel_type == channel_type) + pct_row = (await db.execute(pct_stmt)).one() + p50 = float(pct_row.p50) if pct_row.p50 is not None else None + p90 = float(pct_row.p90) if pct_row.p90 is not None else None + p99 = float(pct_row.p99) if pct_row.p99 is not None else None + max_ms = float(pct_row.max) if pct_row.max is not None else None - return DeliveryLatencyDistribution( - p50_ms=p50, - p90_ms=p90, - p99_ms=p99, - max_ms=max_ms, - histogram=histogram, - ) - except IntegrityError as exc: - raise self._translate_db_error(exc, "channel_outbox") from exc - except SQLAlchemyError as exc: - raise self._translate_db_error(exc, "channel_outbox") from exc - except (NotFoundError, ConflictError, DependencyError): - raise - except Exception as exc: - await self._logger.error( - "channel_outbox_get_delivery_latency_distribution_failed", - resource="channel_outbox", - error=str(exc), - ) - raise DependencyError("channel_outbox", Error(str(exc))) from exc + # 直方图分桶 + latency_bucket = case( + (ChannelOutboxEntryORM.latency_ms <= 100, "0-100ms"), + (ChannelOutboxEntryORM.latency_ms <= 500, "100-500ms"), + (ChannelOutboxEntryORM.latency_ms <= 1000, "500ms-1s"), + (ChannelOutboxEntryORM.latency_ms <= 5000, "1-5s"), + else_="5s+", + ) + hist_stmt = ( + select(latency_bucket, func.count(ChannelOutboxEntryORM.id)) + .where( + ChannelOutboxEntryORM.created_at >= start_naive, + ChannelOutboxEntryORM.created_at < end_naive, + ChannelOutboxEntryORM.is_deleted == 0, + ChannelOutboxEntryORM.latency_ms.isnot(None), + ) + .group_by(latency_bucket) + ) + if channel_type is not None: + hist_stmt = hist_stmt.join( + ChannelAccountORM, + ChannelOutboxEntryORM.account_id == ChannelAccountORM.id, + ).where(ChannelAccountORM.channel_type == channel_type) + hist_result = await db.execute(hist_stmt) + histogram: dict[str, int] = { + "0-100ms": 0, + "100-500ms": 0, + "500ms-1s": 0, + "1-5s": 0, + "5s+": 0, + } + for bucket, count in hist_result.all(): + if bucket is not None and bucket in histogram: + histogram[bucket] = int(count) + + return DeliveryLatencyDistribution( + p50_ms=p50, + p90_ms=p90, + p99_ms=p99, + max_ms=max_ms, + histogram=histogram, + ) + except IntegrityError as exc: + raise self._translate_db_error(exc, "channel_outbox") from exc + except SQLAlchemyError as exc: + raise self._translate_db_error(exc, "channel_outbox") from exc + except (NotFoundError, ConflictError, DependencyError): + raise + except Exception as exc: + await self._logger.error( + "channel_outbox_get_delivery_latency_distribution_failed", + resource="channel_outbox", + error=str(exc), + ) + raise DependencyError("channel_outbox", Error(str(exc))) from exc async def getDeliveryStats( self, @@ -4261,129 +4320,130 @@ class ChannelPersistenceAdapter( Raises: DependencyError: 数据库故障。 """ - try: - # DB 列 created_at 为 TIMESTAMP WITHOUT TIME ZONE(naive UTC 存储), - # DTO 传入 aware UTC datetime,需在 adapter 边界剥离 tzinfo,避免 - # asyncpg "can't subtract offset-naive and offset-aware datetimes"。 - start_naive = _to_naive_utc(query.start_time) - end_naive = _to_naive_utc(query.end_time) - # 全局聚合:总数、成功数、失败数、平均延迟、P95、P99、队列深度、死信数 - agg_stmt = select( - func.count(ChannelOutboxEntryORM.id).label("total"), - func.sum( - case( - (ChannelOutboxEntryORM.status == "sent", 1), - else_=0, - ) - ).label("success"), - func.sum( - case( - ( - ChannelOutboxEntryORM.status.in_(["failed", "dead"]), - 1, - ), - else_=0, - ) - ).label("failure"), - func.avg(ChannelOutboxEntryORM.latency_ms).label("avg_latency"), - func.percentile_cont(0.95).within_group(ChannelOutboxEntryORM.latency_ms).label("p95"), - func.percentile_cont(0.99).within_group(ChannelOutboxEntryORM.latency_ms).label("p99"), - func.sum( - case( - (ChannelOutboxEntryORM.status == "pending", 1), - else_=0, - ) - ).label("queue_depth"), - func.sum( - case( - (ChannelOutboxEntryORM.status == "dead", 1), - else_=0, - ) - ).label("dead_count"), - ).where(ChannelOutboxEntryORM.is_deleted == 0) - if query.channel_type is not None: - agg_stmt = agg_stmt.join( - ChannelAccountORM, - ChannelOutboxEntryORM.account_id == ChannelAccountORM.id, - ).where(ChannelAccountORM.channel_type == query.channel_type) - if start_naive is not None: - agg_stmt = agg_stmt.where(ChannelOutboxEntryORM.created_at >= start_naive) - if end_naive is not None: - agg_stmt = agg_stmt.where(ChannelOutboxEntryORM.created_at < end_naive) - agg_row = (await self._db.execute(agg_stmt)).one() - total_sent = int(agg_row.total or 0) - success_count = int(agg_row.success or 0) - failed_count = int(agg_row.failure or 0) - success_rate = (success_count / total_sent * 100) if total_sent > 0 else None - # 无投递样本时延迟统一返回 None,避免 0ms 被误解为真实低延迟。 - avg_latency = float(agg_row.avg_latency) if total_sent > 0 and agg_row.avg_latency is not None else None - p95 = float(agg_row.p95) if total_sent > 0 and agg_row.p95 is not None else None - p99 = float(agg_row.p99) if total_sent > 0 and agg_row.p99 is not None else None - queue_depth = int(agg_row.queue_depth or 0) - dead_count = int(agg_row.dead_count or 0) - - # 按渠道切片统计(JOIN ChannelAccount 获取 channel_type) - channel_stmt = ( - select( - ChannelAccountORM.channel_type, - func.count(ChannelOutboxEntryORM.id).label("sent"), + async with self._session_scope(None) as (db, _, _): + try: + # DB 列 created_at 为 TIMESTAMP WITHOUT TIME ZONE(naive UTC 存储), + # DTO 传入 aware UTC datetime,需在 adapter 边界剥离 tzinfo,避免 + # asyncpg "can't subtract offset-naive and offset-aware datetimes"。 + start_naive = _to_naive_utc(query.start_time) + end_naive = _to_naive_utc(query.end_time) + # 全局聚合:总数、成功数、失败数、平均延迟、P95、P99、队列深度、死信数 + agg_stmt = select( + func.count(ChannelOutboxEntryORM.id).label("total"), func.sum( case( (ChannelOutboxEntryORM.status == "sent", 1), else_=0, ) ).label("success"), - ) - .join( - ChannelAccountORM, - ChannelOutboxEntryORM.account_id == ChannelAccountORM.id, - ) - .where(ChannelOutboxEntryORM.is_deleted == 0) - .group_by(ChannelAccountORM.channel_type) - ) - if start_naive is not None: - channel_stmt = channel_stmt.where(ChannelOutboxEntryORM.created_at >= start_naive) - if end_naive is not None: - channel_stmt = channel_stmt.where(ChannelOutboxEntryORM.created_at < end_naive) - channel_result = await self._db.execute(channel_stmt) - by_channel: list[ChannelDeliveryStat] = [] - for row in channel_result.all(): - ch_sent = int(row.sent or 0) - ch_success = int(row.success or 0) - ch_rate = (ch_success / ch_sent * 100) if ch_sent > 0 else None - by_channel.append( - ChannelDeliveryStat( - channel_type=row.channel_type, - sent=ch_sent, - success_rate=ch_rate, - ) - ) + func.sum( + case( + ( + ChannelOutboxEntryORM.status.in_(["failed", "dead"]), + 1, + ), + else_=0, + ) + ).label("failure"), + func.avg(ChannelOutboxEntryORM.latency_ms).label("avg_latency"), + func.percentile_cont(0.95).within_group(ChannelOutboxEntryORM.latency_ms).label("p95"), + func.percentile_cont(0.99).within_group(ChannelOutboxEntryORM.latency_ms).label("p99"), + func.sum( + case( + (ChannelOutboxEntryORM.status == "pending", 1), + else_=0, + ) + ).label("queue_depth"), + func.sum( + case( + (ChannelOutboxEntryORM.status == "dead", 1), + else_=0, + ) + ).label("dead_count"), + ).where(ChannelOutboxEntryORM.is_deleted == 0) + if query.channel_type is not None: + agg_stmt = agg_stmt.join( + ChannelAccountORM, + ChannelOutboxEntryORM.account_id == ChannelAccountORM.id, + ).where(ChannelAccountORM.channel_type == query.channel_type) + if start_naive is not None: + agg_stmt = agg_stmt.where(ChannelOutboxEntryORM.created_at >= start_naive) + if end_naive is not None: + agg_stmt = agg_stmt.where(ChannelOutboxEntryORM.created_at < end_naive) + agg_row = (await db.execute(agg_stmt)).one() + total_sent = int(agg_row.total or 0) + success_count = int(agg_row.success or 0) + failed_count = int(agg_row.failure or 0) + success_rate = (success_count / total_sent * 100) if total_sent > 0 else None + # 无投递样本时延迟统一返回 None,避免 0ms 被误解为真实低延迟。 + avg_latency = float(agg_row.avg_latency) if total_sent > 0 and agg_row.avg_latency is not None else None + p95 = float(agg_row.p95) if total_sent > 0 and agg_row.p95 is not None else None + p99 = float(agg_row.p99) if total_sent > 0 and agg_row.p99 is not None else None + queue_depth = int(agg_row.queue_depth or 0) + dead_count = int(agg_row.dead_count or 0) - return DashboardDeliveryResult( - total_sent=total_sent, - success_count=success_count, - failed_count=failed_count, - success_rate=success_rate, - avg_latency_ms=avg_latency, - p95_latency_ms=p95, - p99_latency_ms=p99, - current_queue_depth=queue_depth, - dead_letter_count=dead_count, - by_channel=tuple(by_channel), - ) - except IntegrityError as exc: - raise self._translate_db_error(exc, "channel_outbox") from exc - except SQLAlchemyError as exc: - raise self._translate_db_error(exc, "channel_outbox") from exc - except (NotFoundError, ConflictError, DependencyError): - raise - except Exception as exc: - await self._logger.error( - "channel_outbox_get_delivery_stats_failed", - resource="channel_outbox", - error=str(exc), - ) - raise DependencyError("channel_outbox", Error(str(exc))) from exc + # 按渠道切片统计(JOIN ChannelAccount 获取 channel_type) + channel_stmt = ( + select( + ChannelAccountORM.channel_type, + func.count(ChannelOutboxEntryORM.id).label("sent"), + func.sum( + case( + (ChannelOutboxEntryORM.status == "sent", 1), + else_=0, + ) + ).label("success"), + ) + .join( + ChannelAccountORM, + ChannelOutboxEntryORM.account_id == ChannelAccountORM.id, + ) + .where(ChannelOutboxEntryORM.is_deleted == 0) + .group_by(ChannelAccountORM.channel_type) + ) + if start_naive is not None: + channel_stmt = channel_stmt.where(ChannelOutboxEntryORM.created_at >= start_naive) + if end_naive is not None: + channel_stmt = channel_stmt.where(ChannelOutboxEntryORM.created_at < end_naive) + channel_result = await db.execute(channel_stmt) + by_channel: list[ChannelDeliveryStat] = [] + for row in channel_result.all(): + ch_sent = int(row.sent or 0) + ch_success = int(row.success or 0) + ch_rate = (ch_success / ch_sent * 100) if ch_sent > 0 else None + by_channel.append( + ChannelDeliveryStat( + channel_type=row.channel_type, + sent=ch_sent, + success_rate=ch_rate, + ) + ) + + return DashboardDeliveryResult( + total_sent=total_sent, + success_count=success_count, + failed_count=failed_count, + success_rate=success_rate, + avg_latency_ms=avg_latency, + p95_latency_ms=p95, + p99_latency_ms=p99, + current_queue_depth=queue_depth, + dead_letter_count=dead_count, + by_channel=tuple(by_channel), + ) + except IntegrityError as exc: + raise self._translate_db_error(exc, "channel_outbox") from exc + except SQLAlchemyError as exc: + raise self._translate_db_error(exc, "channel_outbox") from exc + except (NotFoundError, ConflictError, DependencyError): + raise + except Exception as exc: + await self._logger.error( + "channel_outbox_get_delivery_stats_failed", + resource="channel_outbox", + error=str(exc), + ) + raise DependencyError("channel_outbox", Error(str(exc))) from exc async def getDeliveryFunnel( self, @@ -4400,60 +4460,61 @@ class ChannelPersistenceAdapter( Raises: DependencyError: 数据库故障。 """ - try: - # DB 时间列为 naive UTC,剥离 DTO 入参的 tzinfo(见 _to_naive_utc 说明)。 - start_naive = _to_naive_utc(start_time) - end_naive = _to_naive_utc(end_time) - stmt = ( - select( - ChannelOutboxEntryORM.funnel_node, - func.count(ChannelOutboxEntryORM.id), + async with self._session_scope(None) as (db, _, _): + try: + # DB 时间列为 naive UTC,剥离 DTO 入参的 tzinfo(见 _to_naive_utc 说明)。 + start_naive = _to_naive_utc(start_time) + end_naive = _to_naive_utc(end_time) + stmt = ( + select( + ChannelOutboxEntryORM.funnel_node, + func.count(ChannelOutboxEntryORM.id), + ) + .where( + ChannelOutboxEntryORM.created_at >= start_naive, + ChannelOutboxEntryORM.created_at < end_naive, + ChannelOutboxEntryORM.is_deleted == 0, + ) + .group_by(ChannelOutboxEntryORM.funnel_node) ) - .where( - ChannelOutboxEntryORM.created_at >= start_naive, - ChannelOutboxEntryORM.created_at < end_naive, - ChannelOutboxEntryORM.is_deleted == 0, + if channel_type is not None: + stmt = stmt.join( + ChannelAccountORM, + ChannelOutboxEntryORM.account_id == ChannelAccountORM.id, + ).where(ChannelAccountORM.channel_type == channel_type) + result = await db.execute(stmt) + counts = {"enter": 0, "sent": 0, "suppressed": 0, "failed": 0, "dead": 0} + for node, count in result.all(): + if node is not None and node in counts: + counts[node] = int(count) + enter = counts["enter"] + conversion_rate = { + "sent": (counts["sent"] / enter * 100) if enter > 0 else 0.0, + "suppressed": (counts["suppressed"] / enter * 100) if enter > 0 else 0.0, + "failed": (counts["failed"] / enter * 100) if enter > 0 else 0.0, + "dead": (counts["dead"] / enter * 100) if enter > 0 else 0.0, + } + return DeliveryFunnel( + enter=enter, + sent=counts["sent"], + suppressed=counts["suppressed"], + failed=counts["failed"], + dead=counts["dead"], + conversion_rate=conversion_rate, ) - .group_by(ChannelOutboxEntryORM.funnel_node) - ) - if channel_type is not None: - stmt = stmt.join( - ChannelAccountORM, - ChannelOutboxEntryORM.account_id == ChannelAccountORM.id, - ).where(ChannelAccountORM.channel_type == channel_type) - result = await self._db.execute(stmt) - counts = {"enter": 0, "sent": 0, "suppressed": 0, "failed": 0, "dead": 0} - for node, count in result.all(): - if node is not None and node in counts: - counts[node] = int(count) - enter = counts["enter"] - conversion_rate = { - "sent": (counts["sent"] / enter * 100) if enter > 0 else 0.0, - "suppressed": (counts["suppressed"] / enter * 100) if enter > 0 else 0.0, - "failed": (counts["failed"] / enter * 100) if enter > 0 else 0.0, - "dead": (counts["dead"] / enter * 100) if enter > 0 else 0.0, - } - return DeliveryFunnel( - enter=enter, - sent=counts["sent"], - suppressed=counts["suppressed"], - failed=counts["failed"], - dead=counts["dead"], - conversion_rate=conversion_rate, - ) - except IntegrityError as exc: - raise self._translate_db_error(exc, "channel_outbox") from exc - except SQLAlchemyError as exc: - raise self._translate_db_error(exc, "channel_outbox") from exc - except (NotFoundError, ConflictError, DependencyError): - raise - except Exception as exc: - await self._logger.error( - "channel_outbox_get_delivery_funnel_failed", - resource="channel_outbox", - error=str(exc), - ) - raise DependencyError("channel_outbox", Error(str(exc))) from exc + except IntegrityError as exc: + raise self._translate_db_error(exc, "channel_outbox") from exc + except SQLAlchemyError as exc: + raise self._translate_db_error(exc, "channel_outbox") from exc + except (NotFoundError, ConflictError, DependencyError): + raise + except Exception as exc: + await self._logger.error( + "channel_outbox_get_delivery_funnel_failed", + resource="channel_outbox", + error=str(exc), + ) + raise DependencyError("channel_outbox", Error(str(exc))) from exc async def getAccountActivity( self, @@ -4473,96 +4534,97 @@ class ChannelPersistenceAdapter( Raises: DependencyError: 数据库故障。 """ - try: - # DB 时间列为 naive UTC,剥离 DTO 入参的 tzinfo(见 _to_naive_utc 说明)。 - start_naive = _to_naive_utc(query.start_time) - end_naive = _to_naive_utc(query.end_time) - # 消息维度聚合:JOIN Message → Conversation,按 channel_account_id 分组 - msg_stmt = ( - select( - ConversationORM.channel_account_id.label("account_id"), - ConversationORM.channel_type.label("channel_type"), - func.count(MessageORM.id).label("message_count"), - func.count(func.distinct(func.date(MessageORM.created_at))).label("active_days"), - ) - .join( - ConversationORM, - ConversationORM.id == MessageORM.conversation_id, - ) - .where( - MessageORM.created_at >= start_naive, - MessageORM.created_at < end_naive, - ConversationORM.channel_account_id.isnot(None), - ) - .group_by( - ConversationORM.channel_account_id, - ConversationORM.channel_type, - ) - ) - if query.channel_type is not None: - msg_stmt = msg_stmt.where(ConversationORM.channel_type == query.channel_type) - msg_result = await self._db.execute(msg_stmt) - - # 会话维度聚合:JOIN ChannelSession → ChannelAccount,按业务 account_id 分组 - session_stmt = ( - select( - ChannelAccountORM.account_id.label("account_id"), - ChannelAccountORM.channel_type.label("channel_type"), - func.count(ChannelSessionORM.id).label("session_count"), - ) - .join( - ChannelAccountORM, - ChannelAccountORM.id == ChannelSessionORM.account_id, - ) - .where( - ChannelSessionORM.created_at >= start_naive, - ChannelSessionORM.created_at < end_naive, - ChannelSessionORM.is_deleted == 0, - ) - .group_by( - ChannelAccountORM.account_id, - ChannelAccountORM.channel_type, - ) - ) - if query.channel_type is not None: - session_stmt = session_stmt.where(ChannelAccountORM.channel_type == query.channel_type) - session_result = await self._db.execute(session_stmt) - session_map: dict[tuple[str, str], int] = {} - for row in session_result.all(): - session_map[(row.account_id, row.channel_type)] = int(row.session_count) - - stats: list[AccountActivityStat] = [] - for row in msg_result.all(): - account_id = row.account_id - channel_type = row.channel_type or "" - message_count = int(row.message_count) - active_days = int(row.active_days) or 0 - session_count = session_map.get((account_id, channel_type), 0) - avg_daily = message_count / active_days if active_days > 0 else 0.0 - stats.append( - AccountActivityStat( - account_id=account_id, - channel_type=channel_type, - message_count=message_count, - session_count=session_count, - active_days=active_days, - avg_daily_messages=avg_daily, + async with self._session_scope(None) as (db, _, _): + try: + # DB 时间列为 naive UTC,剥离 DTO 入参的 tzinfo(见 _to_naive_utc 说明)。 + start_naive = _to_naive_utc(query.start_time) + end_naive = _to_naive_utc(query.end_time) + # 消息维度聚合:JOIN Message → Conversation,按 channel_account_id 分组 + msg_stmt = ( + select( + ConversationORM.channel_account_id.label("account_id"), + ConversationORM.channel_type.label("channel_type"), + func.count(MessageORM.id).label("message_count"), + func.count(func.distinct(func.date(MessageORM.created_at))).label("active_days"), + ) + .join( + ConversationORM, + ConversationORM.id == MessageORM.conversation_id, + ) + .where( + MessageORM.created_at >= start_naive, + MessageORM.created_at < end_naive, + ConversationORM.channel_account_id.isnot(None), + ) + .group_by( + ConversationORM.channel_account_id, + ConversationORM.channel_type, ) ) - return tuple(stats) - except IntegrityError as exc: - raise self._translate_db_error(exc, "message") from exc - except SQLAlchemyError as exc: - raise self._translate_db_error(exc, "message") from exc - except (NotFoundError, ConflictError, DependencyError): - raise - except Exception as exc: - await self._logger.error( - "message_get_account_activity_failed", - resource="message", - error=str(exc), - ) - raise DependencyError("message", Error(str(exc))) from exc + if query.channel_type is not None: + msg_stmt = msg_stmt.where(ConversationORM.channel_type == query.channel_type) + msg_result = await db.execute(msg_stmt) + + # 会话维度聚合:JOIN ChannelSession → ChannelAccount,按业务 account_id 分组 + session_stmt = ( + select( + ChannelAccountORM.account_id.label("account_id"), + ChannelAccountORM.channel_type.label("channel_type"), + func.count(ChannelSessionORM.id).label("session_count"), + ) + .join( + ChannelAccountORM, + ChannelAccountORM.id == ChannelSessionORM.account_id, + ) + .where( + ChannelSessionORM.created_at >= start_naive, + ChannelSessionORM.created_at < end_naive, + ChannelSessionORM.is_deleted == 0, + ) + .group_by( + ChannelAccountORM.account_id, + ChannelAccountORM.channel_type, + ) + ) + if query.channel_type is not None: + session_stmt = session_stmt.where(ChannelAccountORM.channel_type == query.channel_type) + session_result = await db.execute(session_stmt) + session_map: dict[tuple[str, str], int] = {} + for row in session_result.all(): + session_map[(row.account_id, row.channel_type)] = int(row.session_count) + + stats: list[AccountActivityStat] = [] + for row in msg_result.all(): + account_id = row.account_id + channel_type = row.channel_type or "" + message_count = int(row.message_count) + active_days = int(row.active_days) or 0 + session_count = session_map.get((account_id, channel_type), 0) + avg_daily = message_count / active_days if active_days > 0 else 0.0 + stats.append( + AccountActivityStat( + account_id=account_id, + channel_type=channel_type, + message_count=message_count, + session_count=session_count, + active_days=active_days, + avg_daily_messages=avg_daily, + ) + ) + return tuple(stats) + except IntegrityError as exc: + raise self._translate_db_error(exc, "message") from exc + except SQLAlchemyError as exc: + raise self._translate_db_error(exc, "message") from exc + except (NotFoundError, ConflictError, DependencyError): + raise + except Exception as exc: + await self._logger.error( + "message_get_account_activity_failed", + resource="message", + error=str(exc), + ) + raise DependencyError("message", Error(str(exc))) from exc async def getAccountTrend( self, @@ -4577,51 +4639,52 @@ class ChannelPersistenceAdapter( Raises: DependencyError: 数据库故障。 """ - try: - # DB 时间列为 naive UTC,剥离 DTO 入参的 tzinfo(见 _to_naive_utc 说明)。 - start_naive = _to_naive_utc(query.start_time) - end_naive = _to_naive_utc(query.end_time) - bucket_expr = func.date_trunc(query.granularity, MessageORM.created_at) - stmt = ( - select( - bucket_expr.label("bucket"), - func.count(func.distinct(ConversationORM.channel_account_id)).label("active_accounts"), + async with self._session_scope(None) as (db, _, _): + try: + # DB 时间列为 naive UTC,剥离 DTO 入参的 tzinfo(见 _to_naive_utc 说明)。 + start_naive = _to_naive_utc(query.start_time) + end_naive = _to_naive_utc(query.end_time) + bucket_expr = func.date_trunc(query.granularity, MessageORM.created_at) + stmt = ( + select( + bucket_expr.label("bucket"), + func.count(func.distinct(ConversationORM.channel_account_id)).label("active_accounts"), + ) + .join( + ConversationORM, + ConversationORM.id == MessageORM.conversation_id, + ) + .where( + MessageORM.created_at >= start_naive, + MessageORM.created_at < end_naive, + ConversationORM.channel_account_id.isnot(None), + ) + .group_by(bucket_expr) + .order_by(bucket_expr) ) - .join( - ConversationORM, - ConversationORM.id == MessageORM.conversation_id, + if query.channel_type is not None: + stmt = stmt.where(ConversationORM.channel_type == query.channel_type) + result = await db.execute(stmt) + return tuple( + TrendDataPoint( + timestamp=bucket, + value=int(count), + ) + for bucket, count in result.all() ) - .where( - MessageORM.created_at >= start_naive, - MessageORM.created_at < end_naive, - ConversationORM.channel_account_id.isnot(None), + except IntegrityError as exc: + raise self._translate_db_error(exc, "message") from exc + except SQLAlchemyError as exc: + raise self._translate_db_error(exc, "message") from exc + except (NotFoundError, ConflictError, DependencyError): + raise + except Exception as exc: + await self._logger.error( + "message_get_account_trend_failed", + resource="message", + error=str(exc), ) - .group_by(bucket_expr) - .order_by(bucket_expr) - ) - if query.channel_type is not None: - stmt = stmt.where(ConversationORM.channel_type == query.channel_type) - result = await self._db.execute(stmt) - return tuple( - TrendDataPoint( - timestamp=bucket, - value=int(count), - ) - for bucket, count in result.all() - ) - except IntegrityError as exc: - raise self._translate_db_error(exc, "message") from exc - except SQLAlchemyError as exc: - raise self._translate_db_error(exc, "message") from exc - except (NotFoundError, ConflictError, DependencyError): - raise - except Exception as exc: - await self._logger.error( - "message_get_account_trend_failed", - resource="message", - error=str(exc), - ) - raise DependencyError("message", Error(str(exc))) from exc + raise DependencyError("message", Error(str(exc))) from exc async def getPeerActivity( self, @@ -4640,71 +4703,72 @@ class ChannelPersistenceAdapter( Raises: DependencyError: 数据库故障。 """ - try: - # DB 时间列为 naive UTC,剥离 DTO 入参的 tzinfo(见 _to_naive_utc 说明)。 - start_naive = _to_naive_utc(query.start_time) - end_naive = _to_naive_utc(query.end_time) - stmt = ( - select( - ChannelSessionORM.peer_id.label("peer_id"), - ChannelSessionORM.channel_type.label("channel_type"), - func.count(MessageORM.id).label("message_count"), - func.count(func.distinct(ChannelSessionORM.session_id)).label("session_count"), - func.min(MessageORM.created_at).label("first_seen"), - func.max(MessageORM.created_at).label("last_seen"), + async with self._session_scope(None) as (db, _, _): + try: + # DB 时间列为 naive UTC,剥离 DTO 入参的 tzinfo(见 _to_naive_utc 说明)。 + start_naive = _to_naive_utc(query.start_time) + end_naive = _to_naive_utc(query.end_time) + stmt = ( + select( + ChannelSessionORM.peer_id.label("peer_id"), + ChannelSessionORM.channel_type.label("channel_type"), + func.count(MessageORM.id).label("message_count"), + func.count(func.distinct(ChannelSessionORM.session_id)).label("session_count"), + func.min(MessageORM.created_at).label("first_seen"), + func.max(MessageORM.created_at).label("last_seen"), + ) + .join( + ConversationORM, + ConversationORM.id == MessageORM.conversation_id, + ) + .join( + ChannelSessionORM, + ChannelSessionORM.conversation_id == ConversationORM.id, + ) + .where( + MessageORM.created_at >= start_naive, + MessageORM.created_at < end_naive, + ChannelSessionORM.is_deleted == 0, + ) + .group_by( + ChannelSessionORM.peer_id, + ChannelSessionORM.channel_type, + ) + .order_by(func.count(MessageORM.id).desc()) + .limit(query.limit) ) - .join( - ConversationORM, - ConversationORM.id == MessageORM.conversation_id, + if query.channel_type is not None: + stmt = stmt.where(ChannelSessionORM.channel_type == query.channel_type) + if query.account_id is not None: + stmt = stmt.join( + ChannelAccountORM, + ChannelAccountORM.id == ChannelSessionORM.account_id, + ).where(ChannelAccountORM.account_id == query.account_id) + result = await db.execute(stmt) + return tuple( + PeerActivityStat( + peer_id=row.peer_id, + channel_type=row.channel_type, + message_count=int(row.message_count), + session_count=int(row.session_count), + first_seen=row.first_seen, + last_seen=row.last_seen, + ) + for row in result.all() ) - .join( - ChannelSessionORM, - ChannelSessionORM.conversation_id == ConversationORM.id, + except IntegrityError as exc: + raise self._translate_db_error(exc, "message") from exc + except SQLAlchemyError as exc: + raise self._translate_db_error(exc, "message") from exc + except (NotFoundError, ConflictError, DependencyError): + raise + except Exception as exc: + await self._logger.error( + "message_get_peer_activity_failed", + resource="message", + error=str(exc), ) - .where( - MessageORM.created_at >= start_naive, - MessageORM.created_at < end_naive, - ChannelSessionORM.is_deleted == 0, - ) - .group_by( - ChannelSessionORM.peer_id, - ChannelSessionORM.channel_type, - ) - .order_by(func.count(MessageORM.id).desc()) - .limit(query.limit) - ) - if query.channel_type is not None: - stmt = stmt.where(ChannelSessionORM.channel_type == query.channel_type) - if query.account_id is not None: - stmt = stmt.join( - ChannelAccountORM, - ChannelAccountORM.id == ChannelSessionORM.account_id, - ).where(ChannelAccountORM.account_id == query.account_id) - result = await self._db.execute(stmt) - return tuple( - PeerActivityStat( - peer_id=row.peer_id, - channel_type=row.channel_type, - message_count=int(row.message_count), - session_count=int(row.session_count), - first_seen=row.first_seen, - last_seen=row.last_seen, - ) - for row in result.all() - ) - except IntegrityError as exc: - raise self._translate_db_error(exc, "message") from exc - except SQLAlchemyError as exc: - raise self._translate_db_error(exc, "message") from exc - except (NotFoundError, ConflictError, DependencyError): - raise - except Exception as exc: - await self._logger.error( - "message_get_peer_activity_failed", - resource="message", - error=str(exc), - ) - raise DependencyError("message", Error(str(exc))) from exc + raise DependencyError("message", Error(str(exc))) from exc # ═══════════════════════════════════════════════════════════════════════════ # 路由绑定 CRUD(RouteBindingRepositoryPort) @@ -4726,35 +4790,35 @@ class ChannelPersistenceAdapter( ConflictError: 唯一约束冲突(同账户同 tier 同值已存在未删除规则)。 DependencyError: 数据库故障。 """ - commit = self._should_commit(tx) - data: dict[str, Any] = { - "binding_id": uuid.uuid4().hex, - "channel_type": cmd.channel_type, - "account_id": cmd.account_id, - "match_source": cmd.match_source, - "match_value": cmd.match_value, - "agent_binding": cmd.agent_binding, - "enabled": True, - "description": cmd.description, - "created_by": operator.user_id, - "updated_by": operator.user_id, - } - try: - orm = await self._repos.route_binding.create(data, commit=commit) - return orm_to_route_binding(orm) - except IntegrityError as exc: - raise self._translate_db_error(exc, "route_binding") from exc - except SQLAlchemyError as exc: - raise self._translate_db_error(exc, "route_binding") from exc - except (NotFoundError, ConflictError, DependencyError): - raise - except Exception as exc: - await self._logger.error( - "route_binding_save_failed", - resource="route_binding", - error=str(exc), - ) - raise DependencyError("route_binding", Error(str(exc))) from exc + async with self._session_scope(tx) as (_, repos, commit): + data: dict[str, Any] = { + "binding_id": uuid.uuid4().hex, + "channel_type": cmd.channel_type, + "account_id": cmd.account_id, + "match_source": cmd.match_source, + "match_value": cmd.match_value, + "agent_binding": cmd.agent_binding, + "enabled": True, + "description": cmd.description, + "created_by": operator.user_id, + "updated_by": operator.user_id, + } + try: + orm = await repos.route_binding.create(data, commit=commit) + return orm_to_route_binding(orm) + except IntegrityError as exc: + raise self._translate_db_error(exc, "route_binding") from exc + except SQLAlchemyError as exc: + raise self._translate_db_error(exc, "route_binding") from exc + except (NotFoundError, ConflictError, DependencyError): + raise + except Exception as exc: + await self._logger.error( + "route_binding_save_failed", + resource="route_binding", + error=str(exc), + ) + raise DependencyError("route_binding", Error(str(exc))) from exc async def updateRouteBinding( self, @@ -4774,38 +4838,38 @@ class ChannelPersistenceAdapter( ConflictError: 乐观锁版本不匹配或并发冲突。 DependencyError: 数据库故障。 """ - commit = self._should_commit(tx) - try: - orm = await self._repos.route_binding.get_by_binding_id(cmd.binding_id, for_update=True) - if orm is None: - raise NotFoundError("route_binding", cmd.binding_id) - if orm.version != cmd.expected_version: - raise ConflictError("route_binding") - data: dict[str, Any] = {"updated_by": operator.user_id} - if cmd.agent_binding is not None: - data["agent_binding"] = cmd.agent_binding - if cmd.match_value is not None: - data["match_value"] = cmd.match_value - if cmd.enabled is not None: - data["enabled"] = cmd.enabled - if cmd.description is not None: - data["description"] = cmd.description - orm.version += 1 - updated = await self._repos.route_binding.update(orm, data, commit=commit) - return orm_to_route_binding(updated) - except IntegrityError as exc: - raise self._translate_db_error(exc, "route_binding") from exc - except SQLAlchemyError as exc: - raise self._translate_db_error(exc, "route_binding") from exc - except (NotFoundError, ConflictError, DependencyError): - raise - except Exception as exc: - await self._logger.error( - "route_binding_update_failed", - resource="route_binding", - error=str(exc), - ) - raise DependencyError("route_binding", Error(str(exc))) from exc + async with self._session_scope(tx) as (_, repos, commit): + try: + orm = await repos.route_binding.get_by_binding_id(cmd.binding_id, for_update=True) + if orm is None: + raise NotFoundError("route_binding", cmd.binding_id) + if orm.version != cmd.expected_version: + raise ConflictError("route_binding") + data: dict[str, Any] = {"updated_by": operator.user_id} + if cmd.agent_binding is not None: + data["agent_binding"] = cmd.agent_binding + if cmd.match_value is not None: + data["match_value"] = cmd.match_value + if cmd.enabled is not None: + data["enabled"] = cmd.enabled + if cmd.description is not None: + data["description"] = cmd.description + orm.version += 1 + updated = await repos.route_binding.update(orm, data, commit=commit) + return orm_to_route_binding(updated) + except IntegrityError as exc: + raise self._translate_db_error(exc, "route_binding") from exc + except SQLAlchemyError as exc: + raise self._translate_db_error(exc, "route_binding") from exc + except (NotFoundError, ConflictError, DependencyError): + raise + except Exception as exc: + await self._logger.error( + "route_binding_update_failed", + resource="route_binding", + error=str(exc), + ) + raise DependencyError("route_binding", Error(str(exc))) from exc async def getRouteBinding(self, binding_id: str) -> RouteBindingRule | None: """按 binding_id 查询路由绑定;不存在返回 None。 @@ -4813,24 +4877,25 @@ class ChannelPersistenceAdapter( Raises: DependencyError: 数据库故障。 """ - try: - orm = await self._repos.route_binding.get_by_binding_id(binding_id) - if orm is None: - return None - return orm_to_route_binding(orm) - except IntegrityError as exc: - raise self._translate_db_error(exc, "route_binding") from exc - except SQLAlchemyError as exc: - raise self._translate_db_error(exc, "route_binding") from exc - except (NotFoundError, ConflictError, DependencyError): - raise - except Exception as exc: - await self._logger.error( - "route_binding_get_failed", - resource="route_binding", - error=str(exc), - ) - raise DependencyError("route_binding", Error(str(exc))) from exc + async with self._session_scope(None) as (_, repos, _): + try: + orm = await repos.route_binding.get_by_binding_id(binding_id) + if orm is None: + return None + return orm_to_route_binding(orm) + except IntegrityError as exc: + raise self._translate_db_error(exc, "route_binding") from exc + except SQLAlchemyError as exc: + raise self._translate_db_error(exc, "route_binding") from exc + except (NotFoundError, ConflictError, DependencyError): + raise + except Exception as exc: + await self._logger.error( + "route_binding_get_failed", + resource="route_binding", + error=str(exc), + ) + raise DependencyError("route_binding", Error(str(exc))) from exc async def listRouteBindings( self, @@ -4843,22 +4908,23 @@ class ChannelPersistenceAdapter( Raises: DependencyError: 数据库故障。 """ - try: - orms = await self._repos.route_binding.list(filter=filter, limit=limit, offset=offset) - return tuple(orm_to_route_binding(orm) for orm in orms) - except IntegrityError as exc: - raise self._translate_db_error(exc, "route_binding") from exc - except SQLAlchemyError as exc: - raise self._translate_db_error(exc, "route_binding") from exc - except (NotFoundError, ConflictError, DependencyError): - raise - except Exception as exc: - await self._logger.error( - "route_binding_list_failed", - resource="route_binding", - error=str(exc), - ) - raise DependencyError("route_binding", Error(str(exc))) from exc + async with self._session_scope(None) as (_, repos, _): + try: + orms = await repos.route_binding.list(filter=filter, limit=limit, offset=offset) + return tuple(orm_to_route_binding(orm) for orm in orms) + except IntegrityError as exc: + raise self._translate_db_error(exc, "route_binding") from exc + except SQLAlchemyError as exc: + raise self._translate_db_error(exc, "route_binding") from exc + except (NotFoundError, ConflictError, DependencyError): + raise + except Exception as exc: + await self._logger.error( + "route_binding_list_failed", + resource="route_binding", + error=str(exc), + ) + raise DependencyError("route_binding", Error(str(exc))) from exc async def countRouteBindings( self, @@ -4869,21 +4935,22 @@ class ChannelPersistenceAdapter( Raises: DependencyError: 数据库故障。 """ - try: - return await self._repos.route_binding.count(filter=filter) - except IntegrityError as exc: - raise self._translate_db_error(exc, "route_binding") from exc - except SQLAlchemyError as exc: - raise self._translate_db_error(exc, "route_binding") from exc - except (NotFoundError, ConflictError, DependencyError): - raise - except Exception as exc: - await self._logger.error( - "route_binding_count_failed", - resource="route_binding", - error=str(exc), - ) - raise DependencyError("route_binding", Error(str(exc))) from exc + async with self._session_scope(None) as (_, repos, _): + try: + return await repos.route_binding.count(filter=filter) + except IntegrityError as exc: + raise self._translate_db_error(exc, "route_binding") from exc + except SQLAlchemyError as exc: + raise self._translate_db_error(exc, "route_binding") from exc + except (NotFoundError, ConflictError, DependencyError): + raise + except Exception as exc: + await self._logger.error( + "route_binding_count_failed", + resource="route_binding", + error=str(exc), + ) + raise DependencyError("route_binding", Error(str(exc))) from exc async def deleteRouteBinding( self, @@ -4897,25 +4964,25 @@ class ChannelPersistenceAdapter( NotFoundError: binding_id 不存在(含已软删除)。 DependencyError: 数据库故障。 """ - commit = self._should_commit(tx) - try: - deleted = await self._repos.route_binding.soft_delete(binding_id, operator=operator.user_id, commit=commit) - if not deleted: - raise NotFoundError("route_binding", binding_id) - return deleted - except IntegrityError as exc: - raise self._translate_db_error(exc, "route_binding") from exc - except SQLAlchemyError as exc: - raise self._translate_db_error(exc, "route_binding") from exc - except (NotFoundError, ConflictError, DependencyError): - raise - except Exception as exc: - await self._logger.error( - "route_binding_delete_failed", - resource="route_binding", - error=str(exc), - ) - raise DependencyError("route_binding", Error(str(exc))) from exc + async with self._session_scope(tx) as (_, repos, commit): + try: + deleted = await repos.route_binding.soft_delete(binding_id, operator=operator.user_id, commit=commit) + if not deleted: + raise NotFoundError("route_binding", binding_id) + return deleted + except IntegrityError as exc: + raise self._translate_db_error(exc, "route_binding") from exc + except SQLAlchemyError as exc: + raise self._translate_db_error(exc, "route_binding") from exc + except (NotFoundError, ConflictError, DependencyError): + raise + except Exception as exc: + await self._logger.error( + "route_binding_delete_failed", + resource="route_binding", + error=str(exc), + ) + raise DependencyError("route_binding", Error(str(exc))) from exc def _coerceFilterDateTime(value: Any) -> datetime: diff --git a/backend/package/yuxi/channels/adapters/content_review_repository_adapter.py b/backend/package/yuxi/channels/adapters/content_review_repository_adapter.py index 8c521514..77089e8e 100644 --- a/backend/package/yuxi/channels/adapters/content_review_repository_adapter.py +++ b/backend/package/yuxi/channels/adapters/content_review_repository_adapter.py @@ -28,6 +28,8 @@ yuxi.repositories.channels(仓储层)、sqlalchemy、typing。 from __future__ import annotations +from collections.abc import AsyncIterator, Callable +from contextlib import asynccontextmanager from datetime import datetime from typing import TYPE_CHECKING, Any @@ -232,27 +234,46 @@ class ContentReviewRepositoryAdapter(ContentReviewRepositoryPort): def __init__( self, - db: AsyncSession, + session_factory: Callable[[], AsyncSession], logger: LoggerPort, ) -> None: - """初始化仓储适配器。 + """初始化仓储适配器,注入 session 工厂(不持有 session 实例)。 + + 适配器为无状态协议转换器,可安全注册为应用级单例。每次方法调用 + 通过 ``_session_scope(tx)`` 按需获取 session。 Args: - db: SQLAlchemy 异步会话,所有读写操作复用该会话以保证事务一致性。 + session_factory: 可调用对象,调用返回 ``AsyncSession`` 实例。 logger: 日志被驱动端口,用于记录故障时的异常信息。 """ - self._db = db + self._session_factory = session_factory self._logger = logger - self._repo = ChannelContentReviewRecordRepository(db) - def _should_commit(self, tx: TransactionContext | None) -> bool: - """判断是否由适配器自主提交事务。 + @asynccontextmanager + async def _session_scope( + self, tx: TransactionContext | None + ) -> AsyncIterator[tuple[AsyncSession, ChannelContentReviewRecordRepository, bool]]: + """统一 session 解析与生命周期管理。 - 事务边界由应用层控制:``tx`` 非空时加入应用层事务, - 适配器 **不得** 自主提交(返回 ``False``);``tx`` 为 ``None`` 时 - 按单方法提交(返回 ``True``,向后兼容)。 + - ``tx`` 非空且 ``get_session()`` 返回 session:复用应用层主事务 + session(C-I1 透传),``commit=False``,不 close。 + - ``tx`` 为 ``None`` 或无 session:创建独立 session,``commit=True``, + 异常 rollback,finally close。 """ - return tx is None + if tx is not None: + session = tx.get_session() + if session is not None: + yield session, ChannelContentReviewRecordRepository(session), False + return + session = self._session_factory() + try: + try: + yield session, ChannelContentReviewRecordRepository(session), True + except Exception: + await session.rollback() + raise + finally: + await session.close() def _translate_db_error(self, exc: Exception, resource: str) -> Error: """将数据库异常翻译为契约层错误。 @@ -277,43 +298,35 @@ class ContentReviewRepositoryAdapter(ContentReviewRepositoryPort): ``categories`` / ``detail`` tuple 转为 JSON 兼容的 list。 事务策略: - - ``tx`` 非空时加入应用层事务(COOPERATIVE),使用共享的 - ``AsyncSession``,**不得** 自主提交。 - - ``tx`` 为 ``None`` 时使用共享会话按单方法提交(INDEPENDENT, - 本端口默认 Strong),异常时回滚。 + - ``tx`` 非空时加入应用层事务(COOPERATIVE),复用主事务 + session,**不得** 自主提交。 + - ``tx`` 为 ``None`` 时创建独立 session 按单方法提交 + (INDEPENDENT,本端口默认 Strong),异常时回滚。 Raises: ConflictError: ``review_id`` 已存在(唯一约束冲突)。 DependencyError: 数据库故障。 """ - commit = self._should_commit(tx) - try: - data = _record_to_orm_data(record) - await self._repo.create(data, commit=commit) - except IntegrityError as exc: - if commit: - await self._db.rollback() - raise self._translate_db_error(exc, "content_review_record") from exc - except SQLAlchemyError as exc: - if commit: - await self._db.rollback() - raise self._translate_db_error(exc, "content_review_record") from exc - except (ConflictError, DependencyError): - if commit: - await self._db.rollback() - raise - except Exception as exc: - if commit: - await self._db.rollback() - await self._logger.error( - "content_review_repository_failed", - resource="content_review_repository", - error=str(exc), - ) - raise DependencyError( - "content_review_repository", - Error(str(exc)), - ) from exc + data = _record_to_orm_data(record) + async with self._session_scope(tx) as (_, repo, commit): + try: + await repo.create(data, commit=commit) + except IntegrityError as exc: + raise self._translate_db_error(exc, "content_review_record") from exc + except SQLAlchemyError as exc: + raise self._translate_db_error(exc, "content_review_record") from exc + except (ConflictError, DependencyError): + raise + except Exception as exc: + await self._logger.error( + "content_review_repository_failed", + resource="content_review_repository", + error=str(exc), + ) + raise DependencyError( + "content_review_repository", + Error(str(exc)), + ) from exc async def queryReviewHistory( self, @@ -332,35 +345,36 @@ class ContentReviewRepositoryAdapter(ContentReviewRepositoryPort): Raises: DependencyError: 数据库故障。 """ - try: - orms = await self._repo.list( - channel_type=filter.channel_type if filter.channel_type else None, - account_id=filter.account_id, - verdict=filter.verdict.value if filter.verdict else None, - resource_type=filter.resource_type.value if filter.resource_type else None, - trace_id=filter.trace_id, - start_time=_to_naive_utc(filter.start_time), - end_time=_to_naive_utc(filter.end_time), - limit=limit, - offset=offset, - ) - return tuple(_orm_to_history_item(orm) for orm in orms) - except IntegrityError as exc: - raise self._translate_db_error(exc, "content_review_record") from exc - except SQLAlchemyError as exc: - raise self._translate_db_error(exc, "content_review_record") from exc - except (ConflictError, DependencyError): - raise - except Exception as exc: - await self._logger.error( - "content_review_repository_failed", - resource="content_review_repository", - error=str(exc), - ) - raise DependencyError( - "content_review_repository", - Error(str(exc)), - ) from exc + async with self._session_scope(None) as (_, repo, _): + try: + orms = await repo.list( + channel_type=filter.channel_type if filter.channel_type else None, + account_id=filter.account_id, + verdict=filter.verdict.value if filter.verdict else None, + resource_type=filter.resource_type.value if filter.resource_type else None, + trace_id=filter.trace_id, + start_time=_to_naive_utc(filter.start_time), + end_time=_to_naive_utc(filter.end_time), + limit=limit, + offset=offset, + ) + return tuple(_orm_to_history_item(orm) for orm in orms) + except IntegrityError as exc: + raise self._translate_db_error(exc, "content_review_record") from exc + except SQLAlchemyError as exc: + raise self._translate_db_error(exc, "content_review_record") from exc + except (ConflictError, DependencyError): + raise + except Exception as exc: + await self._logger.error( + "content_review_repository_failed", + resource="content_review_repository", + error=str(exc), + ) + raise DependencyError( + "content_review_repository", + Error(str(exc)), + ) from exc async def countReviewHistory( self, @@ -374,32 +388,33 @@ class ContentReviewRepositoryAdapter(ContentReviewRepositoryPort): Raises: DependencyError: 数据库故障。 """ - try: - return await self._repo.count( - channel_type=filter.channel_type if filter.channel_type else None, - account_id=filter.account_id, - verdict=filter.verdict.value if filter.verdict else None, - resource_type=filter.resource_type.value if filter.resource_type else None, - trace_id=filter.trace_id, - start_time=_to_naive_utc(filter.start_time), - end_time=_to_naive_utc(filter.end_time), - ) - except IntegrityError as exc: - raise self._translate_db_error(exc, "content_review_record") from exc - except SQLAlchemyError as exc: - raise self._translate_db_error(exc, "content_review_record") from exc - except (ConflictError, DependencyError): - raise - except Exception as exc: - await self._logger.error( - "content_review_repository_failed", - resource="content_review_repository", - error=str(exc), - ) - raise DependencyError( - "content_review_repository", - Error(str(exc)), - ) from exc + async with self._session_scope(None) as (_, repo, _): + try: + return await repo.count( + channel_type=filter.channel_type if filter.channel_type else None, + account_id=filter.account_id, + verdict=filter.verdict.value if filter.verdict else None, + resource_type=filter.resource_type.value if filter.resource_type else None, + trace_id=filter.trace_id, + start_time=_to_naive_utc(filter.start_time), + end_time=_to_naive_utc(filter.end_time), + ) + except IntegrityError as exc: + raise self._translate_db_error(exc, "content_review_record") from exc + except SQLAlchemyError as exc: + raise self._translate_db_error(exc, "content_review_record") from exc + except (ConflictError, DependencyError): + raise + except Exception as exc: + await self._logger.error( + "content_review_repository_failed", + resource="content_review_repository", + error=str(exc), + ) + raise DependencyError( + "content_review_repository", + Error(str(exc)), + ) from exc async def getReviewDetail( self, @@ -415,27 +430,28 @@ class ContentReviewRepositoryAdapter(ContentReviewRepositoryPort): Raises: DependencyError: 数据库故障。 """ - try: - orm = await self._repo.get_by_review_id(review_id) - if orm is None: - return None - return _orm_to_detail(orm) - except IntegrityError as exc: - raise self._translate_db_error(exc, "content_review_record") from exc - except SQLAlchemyError as exc: - raise self._translate_db_error(exc, "content_review_record") from exc - except (ConflictError, DependencyError): - raise - except Exception as exc: - await self._logger.error( - "content_review_repository_failed", - resource="content_review_repository", - error=str(exc), - ) - raise DependencyError( - "content_review_repository", - Error(str(exc)), - ) from exc + async with self._session_scope(None) as (_, repo, _): + try: + orm = await repo.get_by_review_id(review_id) + if orm is None: + return None + return _orm_to_detail(orm) + except IntegrityError as exc: + raise self._translate_db_error(exc, "content_review_record") from exc + except SQLAlchemyError as exc: + raise self._translate_db_error(exc, "content_review_record") from exc + except (ConflictError, DependencyError): + raise + except Exception as exc: + await self._logger.error( + "content_review_repository_failed", + resource="content_review_repository", + error=str(exc), + ) + raise DependencyError( + "content_review_repository", + Error(str(exc)), + ) from exc async def getReviewAnalytics( self, @@ -456,38 +472,39 @@ class ContentReviewRepositoryAdapter(ContentReviewRepositoryPort): Raises: DependencyError: 数据库故障。 """ - try: - data = await self._repo.query_analytics( - start_time=_to_naive_utc(query.start_time), - end_time=_to_naive_utc(query.end_time), - channel_type=query.channel_type if query.channel_type else None, - granularity=query.granularity, - ) - return ContentReviewAnalyticsResult( - total_reviews=data["total_reviews"], - block_count=data["block_count"], - block_rate=data["block_rate"], - by_category=tuple( - CategoryStat(category=item["category"], count=item["count"]) for item in data["by_category"] - ), - trend=tuple(data["trend"]), - ) - except IntegrityError as exc: - raise self._translate_db_error(exc, "content_review_record") from exc - except SQLAlchemyError as exc: - raise self._translate_db_error(exc, "content_review_record") from exc - except (ConflictError, DependencyError): - raise - except Exception as exc: - await self._logger.error( - "content_review_repository_failed", - resource="content_review_repository", - error=str(exc), - ) - raise DependencyError( - "content_review_repository", - Error(str(exc)), - ) from exc + async with self._session_scope(None) as (_, repo, _): + try: + data = await repo.query_analytics( + start_time=_to_naive_utc(query.start_time), + end_time=_to_naive_utc(query.end_time), + channel_type=query.channel_type if query.channel_type else None, + granularity=query.granularity, + ) + return ContentReviewAnalyticsResult( + total_reviews=data["total_reviews"], + block_count=data["block_count"], + block_rate=data["block_rate"], + by_category=tuple( + CategoryStat(category=item["category"], count=item["count"]) for item in data["by_category"] + ), + trend=tuple(data["trend"]), + ) + except IntegrityError as exc: + raise self._translate_db_error(exc, "content_review_record") from exc + except SQLAlchemyError as exc: + raise self._translate_db_error(exc, "content_review_record") from exc + except (ConflictError, DependencyError): + raise + except Exception as exc: + await self._logger.error( + "content_review_repository_failed", + resource="content_review_repository", + error=str(exc), + ) + raise DependencyError( + "content_review_repository", + Error(str(exc)), + ) from exc async def getReviewStats( self, @@ -510,52 +527,53 @@ class ContentReviewRepositoryAdapter(ContentReviewRepositoryPort): Raises: DependencyError: 数据库故障。 """ - try: - data = await self._repo.query_stats( - channel_type=query.channel_type if query.channel_type else None, - account_id=query.account_id, - start_time=_to_naive_utc(query.start_time), - end_time=_to_naive_utc(query.end_time), - granularity=query.granularity, - ) - trend = tuple( - ReviewTrendPoint( - timestamp=item["timestamp"], - pass_count=item["pass_count"], - block_count=item["block_count"], + async with self._session_scope(None) as (_, repo, _): + try: + data = await repo.query_stats( + channel_type=query.channel_type if query.channel_type else None, + account_id=query.account_id, + start_time=_to_naive_utc(query.start_time), + end_time=_to_naive_utc(query.end_time), + granularity=query.granularity, ) - for item in data["trend"] - ) - return ContentReviewStatsResult( - total_reviews=data["total_reviews"], - pass_count=data["pass_count"], - review_count=data["review_count"], - block_count=data["block_count"], - pass_rate=data["pass_rate"], - block_rate=data["block_rate"], - manual_intervention_rate=data["manual_intervention_rate"], - avg_decision_seconds=data["avg_decision_seconds"], - by_category=tuple( - CategoryStat(category=item["category"], count=item["count"]) for item in data["by_category"] - ), - trend=trend, - ) - except IntegrityError as exc: - raise self._translate_db_error(exc, "content_review_record") from exc - except SQLAlchemyError as exc: - raise self._translate_db_error(exc, "content_review_record") from exc - except (ConflictError, DependencyError): - raise - except Exception as exc: - await self._logger.error( - "content_review_repository_failed", - resource="content_review_repository", - error=str(exc), - ) - raise DependencyError( - "content_review_repository", - Error(str(exc)), - ) from exc + trend = tuple( + ReviewTrendPoint( + timestamp=item["timestamp"], + pass_count=item["pass_count"], + block_count=item["block_count"], + ) + for item in data["trend"] + ) + return ContentReviewStatsResult( + total_reviews=data["total_reviews"], + pass_count=data["pass_count"], + review_count=data["review_count"], + block_count=data["block_count"], + pass_rate=data["pass_rate"], + block_rate=data["block_rate"], + manual_intervention_rate=data["manual_intervention_rate"], + avg_decision_seconds=data["avg_decision_seconds"], + by_category=tuple( + CategoryStat(category=item["category"], count=item["count"]) for item in data["by_category"] + ), + trend=trend, + ) + except IntegrityError as exc: + raise self._translate_db_error(exc, "content_review_record") from exc + except SQLAlchemyError as exc: + raise self._translate_db_error(exc, "content_review_record") from exc + except (ConflictError, DependencyError): + raise + except Exception as exc: + await self._logger.error( + "content_review_repository_failed", + resource="content_review_repository", + error=str(exc), + ) + raise DependencyError( + "content_review_repository", + Error(str(exc)), + ) from exc async def updateReviewVerdict( self, @@ -577,7 +595,7 @@ class ContentReviewRepositoryAdapter(ContentReviewRepositoryPort): 事务策略: - ``tx`` 非空时加入应用层事务(COOPERATIVE),**不得** 自主提交。 - - ``tx`` 为 ``None`` 时使用共享会话按单方法提交(INDEPENDENT)。 + - ``tx`` 为 ``None`` 时创建独立 session 按单方法提交(INDEPENDENT)。 Returns: 更新后的 ``ContentReviewDetail``,记录不存在时返回 ``None``。 @@ -585,38 +603,28 @@ class ContentReviewRepositoryAdapter(ContentReviewRepositoryPort): Raises: DependencyError: 数据库故障。 """ - commit = self._should_commit(tx) - try: - orm = await self._repo.update_verdict(review_id, verdict, reviewer, commit=commit) - if orm is None: - if commit: - await self._db.rollback() - return None - return _orm_to_detail(orm) - except IntegrityError as exc: - if commit: - await self._db.rollback() - raise self._translate_db_error(exc, "content_review_record") from exc - except SQLAlchemyError as exc: - if commit: - await self._db.rollback() - raise self._translate_db_error(exc, "content_review_record") from exc - except (ConflictError, DependencyError): - if commit: - await self._db.rollback() - raise - except Exception as exc: - if commit: - await self._db.rollback() - await self._logger.error( - "content_review_repository_failed", - resource="content_review_repository", - error=str(exc), - ) - raise DependencyError( - "content_review_repository", - Error(str(exc)), - ) from exc + async with self._session_scope(tx) as (_, repo, commit): + try: + orm = await repo.update_verdict(review_id, verdict, reviewer, commit=commit) + if orm is None: + return None + return _orm_to_detail(orm) + except IntegrityError as exc: + raise self._translate_db_error(exc, "content_review_record") from exc + except SQLAlchemyError as exc: + raise self._translate_db_error(exc, "content_review_record") from exc + except (ConflictError, DependencyError): + raise + except Exception as exc: + await self._logger.error( + "content_review_repository_failed", + resource="content_review_repository", + error=str(exc), + ) + raise DependencyError( + "content_review_repository", + Error(str(exc)), + ) from exc async def deleteOldReviewRecords( self, @@ -634,29 +642,26 @@ class ContentReviewRepositoryAdapter(ContentReviewRepositoryPort): Raises: DependencyError: 数据库故障。 """ - try: - return await self._repo.delete_old_records( - _to_naive_utc(before) or before, - limit=limit, - commit=True, - ) - except IntegrityError as exc: - await self._db.rollback() - raise self._translate_db_error(exc, "content_review_record") from exc - except SQLAlchemyError as exc: - await self._db.rollback() - raise self._translate_db_error(exc, "content_review_record") from exc - except (ConflictError, DependencyError): - await self._db.rollback() - raise - except Exception as exc: - await self._db.rollback() - await self._logger.error( - "content_review_repository_failed", - resource="content_review_repository", - error=str(exc), - ) - raise DependencyError( - "content_review_repository", - Error(str(exc)), - ) from exc + async with self._session_scope(None) as (_, repo, commit): + try: + return await repo.delete_old_records( + _to_naive_utc(before) or before, + limit=limit, + commit=commit, + ) + except IntegrityError as exc: + raise self._translate_db_error(exc, "content_review_record") from exc + except SQLAlchemyError as exc: + raise self._translate_db_error(exc, "content_review_record") from exc + except (ConflictError, DependencyError): + raise + except Exception as exc: + await self._logger.error( + "content_review_repository_failed", + resource="content_review_repository", + error=str(exc), + ) + raise DependencyError( + "content_review_repository", + Error(str(exc)), + ) from exc diff --git a/backend/package/yuxi/channels/adapters/conversation_adapter.py b/backend/package/yuxi/channels/adapters/conversation_adapter.py index 6fb7516e..5b16bc09 100644 --- a/backend/package/yuxi/channels/adapters/conversation_adapter.py +++ b/backend/package/yuxi/channels/adapters/conversation_adapter.py @@ -511,6 +511,12 @@ class ConversationAdapter(ConversationPort): ) if updated is None: raise NotFoundError("message", cmd.message_id) + # update_channel_status 用 db.get 加载 Message,不预加载 + # conversation relationship;commit 后(expire_on_commit=True) + # 关系过期,_orm_to_message 访问 orm.conversation.channel_type 会 + # 触发 lazy-load 抛 MissingGreenlet(SQLAlchemyError 子类)。需显式 + # refresh 加载 relationship,与 saveMessage / _createMessage 一致。 + await self._db.refresh(updated, ["conversation"]) return self._orm_to_message(updated) except IntegrityError as exc: raise self._translate_db_error(exc, "message") from exc diff --git a/backend/package/yuxi/channels/adapters/mappers.py b/backend/package/yuxi/channels/adapters/mappers.py index b5de29b0..002c0f62 100644 --- a/backend/package/yuxi/channels/adapters/mappers.py +++ b/backend/package/yuxi/channels/adapters/mappers.py @@ -51,6 +51,22 @@ from yuxi.storage.postgres.models_channels import ( from yuxi.utils.crypto import decrypt_sensitive_fields, encrypt_sensitive_fields +def _to_aware_utc(value: dt.datetime | None) -> dt.datetime | None: + """将 ORM 读出的 naive UTC datetime 转换为 aware UTC datetime。 + + Postgres ``TIMESTAMP WITHOUT TIME ZONE`` 列由 SQLAlchemy 映射为 + offset-naive datetime;领域层统一使用 ``datetime.now(UTC)`` 等 + offset-aware datetime 运算。本 helper 在 adapter→core 边界处转换, + 避免 ``can't subtract offset-naive and offset-aware datetimes`` 等 + 时区混用错误。已是 aware 的值原样返回。 + """ + if value is None: + return None + if value.tzinfo is None: + return value.replace(tzinfo=dt.UTC) + return value + + def _enum[T, V](value: V, cls: type[T], resource: str) -> T: """安全构造枚举,将非法值翻译为 DependencyError。 @@ -319,19 +335,19 @@ def orm_to_outbox_entry( durability_policy=_enum(orm.durability_policy, MessageDurabilityPolicy, "outbox_entry"), retry_count=orm.retry_count, max_retry=orm.max_retry, - next_retry_at=orm.next_retry_at, + next_retry_at=_to_aware_utc(orm.next_retry_at), last_error=orm.last_error, - created_at=orm.created_at, - updated_at=orm.updated_at, - expires_at=orm.expires_at, + created_at=_to_aware_utc(orm.created_at), + updated_at=_to_aware_utc(orm.updated_at), + expires_at=_to_aware_utc(orm.expires_at), channel_msg_id=orm.channel_msg_id, version=orm.version, channel_session_id=str(orm.channel_session_id) if orm.channel_session_id is not None else None, # 聚合视图域 analytics 子域字段(延迟分布 / 漏斗聚合查询) latency_ms=orm.latency_ms, funnel_node=orm.funnel_node, - sent_at=orm.sent_at, - last_retry_at=orm.last_retry_at, + sent_at=_to_aware_utc(orm.sent_at), + last_retry_at=_to_aware_utc(orm.last_retry_at), channel_type=ChannelType(account_orm.channel_type), # 投递原子语义与降级追踪字段(O-02/O-09/O-10/O-11/O-16) idempotency_key=orm.idempotency_key, diff --git a/backend/package/yuxi/channels/adapters/sqlalchemy_transaction_adapter.py b/backend/package/yuxi/channels/adapters/sqlalchemy_transaction_adapter.py index 2edef08c..0d290dab 100644 --- a/backend/package/yuxi/channels/adapters/sqlalchemy_transaction_adapter.py +++ b/backend/package/yuxi/channels/adapters/sqlalchemy_transaction_adapter.py @@ -2,12 +2,16 @@ 实现 ``TransactionPort`` 契约,基于 SQLAlchemy ``AsyncSession`` 提供事务 边界控制能力。事务边界由应用层(管道或用例编排器)显式调用,被驱动适配 -器通过构造时共享的 ``AsyncSession`` 加入同一事务,**不得** 自主提交。 +器通过 ``TransactionContext`` 透传(C-I1)加入同一事务,**不得** 自主提交。 -事务共享机制:``create_driven_adapters(db)`` 将同一 ``AsyncSession`` -注入到 ``ChannelPersistenceAdapter`` / ``ConversationAdapter`` / -``SqlAlchemyTransactionAdapter``,``begin()`` 在共享 session 上开启 -事务,所有适配器的写操作自动加入。 +事务共享机制:``create_driven_adapters(db, session_factory, ...)`` 将同一 +请求级 ``AsyncSession`` 注入到 ``ConversationAdapter`` / +``SqlAlchemyTransactionAdapter``(请求级事务边界适配器,共享 ``db`` 保证 +事务一致性);``ChannelPersistenceAdapter`` 为无状态适配器(注入 +``session_factory``),通过 ``_session_scope(tx)`` 按需获取 session: +``tx`` 非空时复用 ``tx.get_session()`` 返回的请求级 ``db``(加入应用层 +事务),``tx`` 为 ``None`` 时通过 ``session_factory()`` 创建独立 session +并自主提交。 """ from __future__ import annotations diff --git a/backend/package/yuxi/channels/application/extension/scheduler_handlers/channel_audit_log_retention_handler.py b/backend/package/yuxi/channels/application/extension/scheduler_handlers/channel_audit_log_retention_handler.py index 77743583..ea890263 100644 --- a/backend/package/yuxi/channels/application/extension/scheduler_handlers/channel_audit_log_retention_handler.py +++ b/backend/package/yuxi/channels/application/extension/scheduler_handlers/channel_audit_log_retention_handler.py @@ -5,8 +5,9 @@ 设计要点: -- **会话隔离**:通过构造函数注入 ``session_factory``,每次执行创建独立 db - 会话与适配器,避免 worker 进程长生命周期会话问题。 +- **无状态仓储**:通过构造函数注入无状态仓储端口实例(适配器内部通过 + ``_session_scope(tx)`` 按需获取 session,``tx`` 为 None 时自主创建并提交), + 避免长生命周期会话问题。 - **批量处理**:单次执行最多删除 ``batch_size`` 条,避免长事务锁竞争。 - **分布式锁**:``execute`` 通过 ``acquireAdvisoryLock`` 串行化多 worker 并发执行(lock_key=``scheduler:{class_name}``, TTL=300s),锁获取失败时 @@ -19,12 +20,8 @@ from __future__ import annotations -from collections.abc import Callable -from contextlib import AbstractAsyncContextManager from datetime import timedelta -from sqlalchemy.ext.asyncio import AsyncSession - from yuxi.channels.contract.errors import DependencyError from yuxi.channels.contract.ports.driven.audit_log_repository_port import ( AuditLogRepositoryPort, @@ -47,11 +44,8 @@ class ChannelAuditLogRetentionHandler: batch_size: 单次执行最多删除的记录数,默认 1000。 依赖注入: - session_factory: 调用返回 ``AsyncSession`` 的异步上下文管理器工厂, - 通常为 ``pg_manager.get_async_session_context``。每次执行创建独立 - 会话,避免长生命周期会话问题。 - audit_log_repo_factory: 审计日志仓储端口工厂,接收 ``AsyncSession`` - 返回 ``AuditLogRepositoryPort`` 实例。 + audit_log_repo: 审计日志仓储端口实例(无状态,内部通过 + ``_session_scope(tx)`` 按需获取 session)。 cache_port: 缓存端口,用于获取分布式锁串行化多 worker 并发执行。 logger: 日志端口实例。 """ @@ -66,13 +60,11 @@ class ChannelAuditLogRetentionHandler: def __init__( self, - session_factory: Callable[[], AbstractAsyncContextManager[AsyncSession]], - audit_log_repo_factory: Callable[[AsyncSession], AuditLogRepositoryPort], + audit_log_repo: AuditLogRepositoryPort, cache_port: CachePort, logger: LoggerPort, ) -> None: - self._session_factory = session_factory - self._audit_log_repo_factory = audit_log_repo_factory + self._audit_log_repo = audit_log_repo self._cache_port = cache_port self._logger = logger @@ -109,9 +101,8 @@ class ChannelAuditLogRetentionHandler: 流程: 1. 从 ctx.payload 读取 retention_days 与 batch_size。 2. 计算保留截止时间(当前时间 - retention_days)。 - 3. 通过 session_factory 创建独立 session + 适配器。 - 4. 调用 ``deleteOldAuditLogs`` 物理删除过期日志(最多 batch_size 条)。 - 5. 记录结构化日志,返回 TaskResult(success=True, output={...})。 + 3. 调用 ``deleteOldAuditLogs`` 物理删除过期日志(最多 batch_size 条)。 + 4. 记录结构化日志,返回 TaskResult(success=True, output={...})。 """ retention_days: int = ctx.payload.get( "retention_days", @@ -124,12 +115,10 @@ class ChannelAuditLogRetentionHandler: cutoff = utc_now_naive() - timedelta(days=retention_days) try: - async with self._session_factory() as db: - audit_log_repo = self._audit_log_repo_factory(db) - deleted_count = await audit_log_repo.deleteOldAuditLogs( - cutoff, - limit=batch_size, - ) + deleted_count = await self._audit_log_repo.deleteOldAuditLogs( + cutoff, + limit=batch_size, + ) if deleted_count > 0: await self._logger.info( diff --git a/backend/package/yuxi/channels/application/extension/scheduler_handlers/channel_content_review_retention_handler.py b/backend/package/yuxi/channels/application/extension/scheduler_handlers/channel_content_review_retention_handler.py index 11546103..9148636b 100644 --- a/backend/package/yuxi/channels/application/extension/scheduler_handlers/channel_content_review_retention_handler.py +++ b/backend/package/yuxi/channels/application/extension/scheduler_handlers/channel_content_review_retention_handler.py @@ -6,8 +6,9 @@ 设计要点: -- **会话隔离**:通过构造函数注入 ``session_factory``,每次执行创建独立 db - 会话与适配器,避免 worker 进程长生命周期会话问题。 +- **无状态仓储**:通过构造函数注入无状态仓储端口实例(适配器内部通过 + ``_session_scope(tx)`` 按需获取 session,``tx`` 为 None 时自主创建并提交), + 避免长生命周期会话问题。 - **批量处理**:单次执行最多删除 ``batch_size`` 条,避免长事务锁竞争。 - **分布式锁**:``execute`` 通过 ``acquireAdvisoryLock`` 串行化多 worker 并发执行(lock_key=``scheduler:{class_name}``, TTL=300s),锁获取失败时 @@ -20,12 +21,8 @@ from __future__ import annotations -from collections.abc import Callable -from contextlib import AbstractAsyncContextManager from datetime import timedelta -from sqlalchemy.ext.asyncio import AsyncSession - from yuxi.channels.contract.errors import DependencyError from yuxi.channels.contract.ports.driven.cache_port import CachePort from yuxi.channels.contract.ports.driven.content_review_repository_port import ( @@ -49,11 +46,8 @@ class ChannelContentReviewRetentionHandler: batch_size: 单次执行最多删除的记录数,默认 1000。 依赖注入: - session_factory: 调用返回 ``AsyncSession`` 的异步上下文管理器工厂, - 通常为 ``pg_manager.get_async_session_context``。每次执行创建独立 - 会话,避免长生命周期会话问题。 - content_review_repo_factory: 内容审核仓储端口工厂,接收 ``AsyncSession`` - 返回 ``ContentReviewRepositoryPort`` 实例。 + content_review_repo: 内容审核仓储端口实例(无状态,内部通过 + ``_session_scope(tx)`` 按需获取 session)。 cache_port: 缓存端口,用于获取分布式锁串行化多 worker 并发执行。 logger: 日志端口实例。 """ @@ -68,13 +62,11 @@ class ChannelContentReviewRetentionHandler: def __init__( self, - session_factory: Callable[[], AbstractAsyncContextManager[AsyncSession]], - content_review_repo_factory: Callable[[AsyncSession], ContentReviewRepositoryPort], + content_review_repo: ContentReviewRepositoryPort, cache_port: CachePort, logger: LoggerPort, ) -> None: - self._session_factory = session_factory - self._content_review_repo_factory = content_review_repo_factory + self._content_review_repo = content_review_repo self._cache_port = cache_port self._logger = logger @@ -111,9 +103,8 @@ class ChannelContentReviewRetentionHandler: 流程: 1. 从 ctx.payload 读取 retention_days 与 batch_size。 2. 计算保留截止时间(当前时间 - retention_days)。 - 3. 通过 session_factory 创建独立 session + 适配器。 - 4. 调用 ``deleteOldReviewRecords`` 物理删除过期记录(最多 batch_size 条)。 - 5. 记录结构化日志,返回 TaskResult(success=True, output={...})。 + 3. 调用 ``deleteOldReviewRecords`` 物理删除过期记录(最多 batch_size 条)。 + 4. 记录结构化日志,返回 TaskResult(success=True, output={...})。 """ retention_days: int = ctx.payload.get( "retention_days", @@ -126,12 +117,10 @@ class ChannelContentReviewRetentionHandler: cutoff = utc_now_naive() - timedelta(days=retention_days) try: - async with self._session_factory() as db: - review_repo = self._content_review_repo_factory(db) - deleted_count = await review_repo.deleteOldReviewRecords( - cutoff, - limit=batch_size, - ) + deleted_count = await self._content_review_repo.deleteOldReviewRecords( + cutoff, + limit=batch_size, + ) if deleted_count > 0: await self._logger.info( diff --git a/backend/package/yuxi/channels/application/extension/scheduler_handlers/channel_idempotency_cleanup_handler.py b/backend/package/yuxi/channels/application/extension/scheduler_handlers/channel_idempotency_cleanup_handler.py index c2a43bc5..fbb828ac 100644 --- a/backend/package/yuxi/channels/application/extension/scheduler_handlers/channel_idempotency_cleanup_handler.py +++ b/backend/package/yuxi/channels/application/extension/scheduler_handlers/channel_idempotency_cleanup_handler.py @@ -6,8 +6,9 @@ 设计要点: -- **会话隔离**:通过构造函数注入 ``session_factory``,每次执行创建独立 db - 会话与适配器,避免 worker 进程长生命周期会话问题。 +- **无状态仓储**:通过构造函数注入无状态仓储端口实例(适配器内部通过 + ``_session_scope(tx)`` 按需获取 session,``tx`` 为 None 时自主创建并提交), + 避免长生命周期会话问题。 - **分布式锁**:``execute`` 通过 ``acquireAdvisoryLock`` 串行化多 worker 并发执行(lock_key=``scheduler:{class_name}``, TTL=300s),锁获取失败时 skip(返回 ``TaskResult(skipped=True)``,不 fail-closed,下一周期重试)。 @@ -19,11 +20,6 @@ from __future__ import annotations -from collections.abc import Callable -from contextlib import AbstractAsyncContextManager - -from sqlalchemy.ext.asyncio import AsyncSession - from yuxi.channels.contract.errors import DependencyError from yuxi.channels.contract.ports.driven.cache_port import CachePort from yuxi.channels.contract.ports.driven.idempotency_repository_port import ( @@ -42,11 +38,8 @@ class ChannelIdempotencyCleanupHandler: ``system:channel_idempotency_cleanup`` 任务配置为每小时执行一次。 依赖注入: - session_factory: 调用返回 ``AsyncSession`` 的异步上下文管理器工厂, - 通常为 ``pg_manager.get_async_session_context``。每次执行创建独立 - 会话,避免长生命周期会话问题。 - idempotency_repo_factory: 幂等记录仓储端口工厂,接收 ``AsyncSession`` - 返回 ``IdempotencyRepositoryPort`` 实例。 + idempotency_repo: 幂等记录仓储端口实例(无状态,内部通过 + ``_session_scope(tx)`` 按需获取 session)。 cache_port: 缓存端口,用于获取分布式锁串行化多 worker 并发执行。 logger: 日志端口实例。 """ @@ -58,13 +51,11 @@ class ChannelIdempotencyCleanupHandler: def __init__( self, - session_factory: Callable[[], AbstractAsyncContextManager[AsyncSession]], - idempotency_repo_factory: Callable[[AsyncSession], IdempotencyRepositoryPort], + idempotency_repo: IdempotencyRepositoryPort, cache_port: CachePort, logger: LoggerPort, ) -> None: - self._session_factory = session_factory - self._idempotency_repo_factory = idempotency_repo_factory + self._idempotency_repo = idempotency_repo self._cache_port = cache_port self._logger = logger @@ -100,15 +91,12 @@ class ChannelIdempotencyCleanupHandler: 流程: 1. 以当前时间作为截止时间。 - 2. 通过 session_factory 创建独立 session + 适配器。 - 3. 调用 ``deleteExpiredRecords`` 物理删除 ``expires_at`` 已过期记录。 - 4. 记录结构化日志,返回 TaskResult(success=True, output={...})。 + 2. 调用 ``deleteExpiredRecords`` 物理删除 ``expires_at`` 已过期记录。 + 3. 记录结构化日志,返回 TaskResult(success=True, output={...})。 """ now = utc_now_naive() try: - async with self._session_factory() as db: - idempotency_repo = self._idempotency_repo_factory(db) - deleted_count = await idempotency_repo.deleteExpiredRecords(now) + deleted_count = await self._idempotency_repo.deleteExpiredRecords(now) if deleted_count > 0: await self._logger.info( diff --git a/backend/package/yuxi/channels/application/extension/scheduler_handlers/channel_outbox_recovery_handler.py b/backend/package/yuxi/channels/application/extension/scheduler_handlers/channel_outbox_recovery_handler.py index 12c46d43..0fbd3193 100644 --- a/backend/package/yuxi/channels/application/extension/scheduler_handlers/channel_outbox_recovery_handler.py +++ b/backend/package/yuxi/channels/application/extension/scheduler_handlers/channel_outbox_recovery_handler.py @@ -10,8 +10,9 @@ 设计要点: -- **会话隔离**:通过构造函数注入 ``session_factory``,每次执行创建独立 db - 会话与适配器,避免 worker 进程长生命周期会话问题。 +- **无状态仓储**:通过构造函数注入无状态仓储端口实例(适配器内部通过 + ``_session_scope(tx)`` 按需获取 session,``tx`` 为 None 时自主创建并提交), + 避免长生命周期会话问题。 - **批量处理**:单次执行最多处理 ``batch_size`` 条,避免长事务锁竞争。 - **分布式锁**:``execute`` 通过 ``acquireAdvisoryLock`` 串行化多 worker 并发执行(lock_key=``scheduler:{class_name}``, TTL=300s),锁获取失败时 @@ -26,12 +27,8 @@ from __future__ import annotations -from collections.abc import Callable -from contextlib import AbstractAsyncContextManager from datetime import datetime, timedelta -from sqlalchemy.ext.asyncio import AsyncSession - from yuxi.channels.contract.dtos.audit import AuditOperationType from yuxi.channels.contract.dtos.outbox import OutboxConfig, OutboxEntry, OutboxStatus from yuxi.channels.contract.dtos.persistence import SaveAuditLogCmd @@ -65,13 +62,9 @@ class ChannelOutboxRecoveryHandler: 默认 300。 依赖注入: - session_factory: 调用返回 ``AsyncSession`` 的异步上下文管理器工厂, - 通常为 ``pg_manager.get_async_session_context``。每次执行创建独立 - 会话,避免长生命周期会话问题。 - outbox_repo_factory: outbox 仓储端口工厂,接收 ``AsyncSession`` 返回 - ``OutboxRepositoryPort`` 实例。 - audit_log_repo_factory: 审计日志仓储端口工厂,接收 ``AsyncSession`` - 返回 ``AuditLogRepositoryPort`` 实例。 + outbox_repo: outbox 仓储端口实例(无状态,内部通过 + ``_session_scope(tx)`` 按需获取 session)。 + audit_log_repo: 审计日志仓储端口实例(无状态,同上)。 queue_port: 队列被驱动端口,用于入队 ARQ 重试任务。 outbox_config: 发件箱配置,提供 ``ttl_seconds`` / ``max_retry`` / ``retry_backoff_schedule`` 等参数。 @@ -93,17 +86,15 @@ class ChannelOutboxRecoveryHandler: def __init__( self, - session_factory: Callable[[], AbstractAsyncContextManager[AsyncSession]], - outbox_repo_factory: Callable[[AsyncSession], OutboxRepositoryPort], - audit_log_repo_factory: Callable[[AsyncSession], AuditLogRepositoryPort], + outbox_repo: OutboxRepositoryPort, + audit_log_repo: AuditLogRepositoryPort, queue_port: QueuePort, outbox_config: OutboxConfig, cache_port: CachePort, logger: LoggerPort, ) -> None: - self._session_factory = session_factory - self._outbox_repo_factory = outbox_repo_factory - self._audit_log_repo_factory = audit_log_repo_factory + self._outbox_repo = outbox_repo + self._audit_log_repo = audit_log_repo self._queue = queue_port self._outbox_config = outbox_config self._cache_port = cache_port @@ -143,12 +134,11 @@ class ChannelOutboxRecoveryHandler: 流程: 1. 从 ctx.payload 读取 batch_size / scan_ttl_seconds / sent_unconfirmed_timeout_seconds。 - 2. 通过 session_factory 创建独立 session + 适配器。 - 3. 扫描 PENDING 条目:死信标记 DEAD,其余入队重试。 - 4. 扫描 SENT_UNCONFIRMED 条目:死信标记 DEAD,无回执超时入队重试。 - 5. 扫描 FAILED 条目(O-04):未达最大重试且已到重试时间则重置为 + 2. 扫描 PENDING 条目:死信标记 DEAD,其余入队重试。 + 3. 扫描 SENT_UNCONFIRMED 条目:死信标记 DEAD,无回执超时入队重试。 + 4. 扫描 FAILED 条目(O-04):未达最大重试且已到重试时间则重置为 PENDING 并入队;满足死信条件标记 DEAD。 - 6. 记录结构化日志,返回 TaskResult(success=True, output={...})。 + 5. 记录结构化日志,返回 TaskResult(success=True, output={...})。 """ batch_size: int = ctx.payload.get( "batch_size", @@ -168,51 +158,47 @@ class ChannelOutboxRecoveryHandler: processed_ids: set[str] = set() try: - async with self._session_factory() as db: - outbox_repo = self._outbox_repo_factory(db) - audit_log_repo = self._audit_log_repo_factory(db) - - await self._scan_pending( - outbox_repo, - audit_log_repo, - scan_start, - processed_ids, - batch_size, - scan_ttl_seconds, + await self._scan_pending( + self._outbox_repo, + self._audit_log_repo, + scan_start, + processed_ids, + batch_size, + scan_ttl_seconds, + ) + if self._is_scan_expired(scan_start, scan_ttl_seconds): + return TaskResult( + success=True, + output={ + "processed_count": len(processed_ids), + "reason": "scan_ttl_reached_after_pending", + }, ) - if self._is_scan_expired(scan_start, scan_ttl_seconds): - return TaskResult( - success=True, - output={ - "processed_count": len(processed_ids), - "reason": "scan_ttl_reached_after_pending", - }, - ) - await self._scan_sent_unconfirmed( - outbox_repo, - audit_log_repo, - scan_start, - processed_ids, - batch_size, - scan_ttl_seconds, - sent_unconfirmed_timeout, - ) - if self._is_scan_expired(scan_start, scan_ttl_seconds): - return TaskResult( - success=True, - output={ - "processed_count": len(processed_ids), - "reason": "scan_ttl_reached_after_sent_unconfirmed", - }, - ) - await self._scan_failed( - outbox_repo, - audit_log_repo, - scan_start, - processed_ids, - batch_size, - scan_ttl_seconds, + await self._scan_sent_unconfirmed( + self._outbox_repo, + self._audit_log_repo, + scan_start, + processed_ids, + batch_size, + scan_ttl_seconds, + sent_unconfirmed_timeout, + ) + if self._is_scan_expired(scan_start, scan_ttl_seconds): + return TaskResult( + success=True, + output={ + "processed_count": len(processed_ids), + "reason": "scan_ttl_reached_after_sent_unconfirmed", + }, ) + await self._scan_failed( + self._outbox_repo, + self._audit_log_repo, + scan_start, + processed_ids, + batch_size, + scan_ttl_seconds, + ) await self._logger.info( "outbox 恢复扫描完成", diff --git a/backend/package/yuxi/channels/application/extension/scheduler_handlers/channel_outbox_terminal_cleanup_handler.py b/backend/package/yuxi/channels/application/extension/scheduler_handlers/channel_outbox_terminal_cleanup_handler.py index d213808f..73c25cd4 100644 --- a/backend/package/yuxi/channels/application/extension/scheduler_handlers/channel_outbox_terminal_cleanup_handler.py +++ b/backend/package/yuxi/channels/application/extension/scheduler_handlers/channel_outbox_terminal_cleanup_handler.py @@ -6,8 +6,9 @@ SENT 条目保留 30 天(默认),保留期内可供审计与排查。 设计要点: -- **会话隔离**:通过构造函数注入 ``session_factory``,每次执行创建独立 db - 会话与适配器,避免 worker 进程长生命周期会话问题。 +- **无状态仓储**:通过构造函数注入无状态仓储端口实例(适配器内部通过 + ``_session_scope(tx)`` 按需获取 session,``tx`` 为 None 时自主创建并提交), + 避免长生命周期会话问题。 - **批量处理**:单次执行最多清理 ``batch_size`` 条,避免长事务锁竞争。 - **分布式锁**:``execute`` 通过 ``acquireAdvisoryLock`` 串行化多 worker 并发执行(lock_key=``scheduler:{class_name}``, TTL=300s),锁获取失败时 @@ -23,12 +24,8 @@ SENT 条目保留 30 天(默认),保留期内可供审计与排查。 from __future__ import annotations -from collections.abc import Callable -from contextlib import AbstractAsyncContextManager from datetime import UTC, datetime, timedelta -from sqlalchemy.ext.asyncio import AsyncSession - from yuxi.channels.contract.dtos.event import OutboxEntryPurgedEvent from yuxi.channels.contract.errors import DependencyError from yuxi.channels.contract.ports.driven.cache_port import CachePort @@ -55,12 +52,9 @@ class ChannelOutboxTerminalCleanupHandler: batch_size: 单次执行每种状态最多清理的条目数,默认 1000。 依赖注入: - session_factory: 调用返回 ``AsyncSession`` 的异步上下文管理器工厂, - 通常为 ``pg_manager.get_async_session_context``。每次执行创建独立 - 会话,避免长生命周期会话问题。 - outbox_repo_factory: outbox 仓储端口工厂,接收 ``AsyncSession`` 返回 - ``OutboxRepositoryPort`` 实例。由 infrastructure 层装配具体适配器 - (如 ``SqlAlchemyOutboxRepositoryAdapter``),应用层不直接依赖 + outbox_repo: outbox 仓储端口实例。由 infrastructure 层装配具体适配器 + (如 ``ChannelPersistenceAdapter``),无状态(内部通过 + ``_session_scope(tx)`` 按需获取 session),应用层不直接依赖 适配器实现(INV-1 依赖方向合规)。 event_publisher: 事件发布端口实例或 None(worker 进程未注入时为 None)。 cache_port: 缓存端口,用于获取分布式锁串行化多 worker 并发执行。 @@ -74,14 +68,12 @@ class ChannelOutboxTerminalCleanupHandler: def __init__( self, - session_factory: Callable[[], AbstractAsyncContextManager[AsyncSession]], - outbox_repo_factory: Callable[[AsyncSession], OutboxRepositoryPort], + outbox_repo: OutboxRepositoryPort, event_publisher: EventPublisherPort | None, cache_port: CachePort, logger: LoggerPort, ) -> None: - self._session_factory = session_factory - self._outbox_repo_factory = outbox_repo_factory + self._outbox_repo = outbox_repo self._event_publisher = event_publisher self._cache_port = cache_port self._logger = logger @@ -121,11 +113,10 @@ class ChannelOutboxTerminalCleanupHandler: 1. 从 ctx.payload 读取 dead_retention_hours / sent_retention_hours / batch_size。 2. 计算 dead_before 与 sent_before 截止时间。 - 3. 通过 session_factory 创建独立 session + 适配器。 - 4. 调用 cleanupOldDeadEntries 清理 DEAD 条目。 - 5. 调用 cleanupOldSentEntries 清理 SENT 条目。 - 6. best-effort 发布 OutboxEntryPurgedEvent(event_publisher 非空时)。 - 7. 记录结构化日志,返回 TaskResult(success=True, output={...})。 + 3. 调用 cleanupOldDeadEntries 清理 DEAD 条目。 + 4. 调用 cleanupOldSentEntries 清理 SENT 条目。 + 5. best-effort 发布 OutboxEntryPurgedEvent(event_publisher 非空时)。 + 6. 记录结构化日志,返回 TaskResult(success=True, output={...})。 """ dead_retention_hours: int = ctx.payload.get("dead_retention_hours", 168) sent_retention_hours: int = ctx.payload.get("sent_retention_hours", 720) @@ -135,17 +126,14 @@ class ChannelOutboxTerminalCleanupHandler: sent_before = datetime.now(UTC) - timedelta(hours=sent_retention_hours) try: - async with self._session_factory() as db: - outbox_repo = self._outbox_repo_factory(db) - - dead_ids = await outbox_repo.cleanupOldDeadEntries( - before=dead_before, - limit=batch_size, - ) - sent_ids = await outbox_repo.cleanupOldSentEntries( - before=sent_before, - limit=batch_size, - ) + dead_ids = await self._outbox_repo.cleanupOldDeadEntries( + before=dead_before, + limit=batch_size, + ) + sent_ids = await self._outbox_repo.cleanupOldSentEntries( + before=sent_before, + limit=batch_size, + ) # best-effort 发布事件,携带被清理条目的完整 outbox_id 列表 if self._event_publisher is not None: diff --git a/backend/package/yuxi/channels/application/extension/scheduler_handlers/channel_pairing_expiration_handler.py b/backend/package/yuxi/channels/application/extension/scheduler_handlers/channel_pairing_expiration_handler.py index d68e304a..efd7f000 100644 --- a/backend/package/yuxi/channels/application/extension/scheduler_handlers/channel_pairing_expiration_handler.py +++ b/backend/package/yuxi/channels/application/extension/scheduler_handlers/channel_pairing_expiration_handler.py @@ -6,8 +6,9 @@ PENDING 状态的配对记录,通过聚合根方法 ``PairingApproval.expireIf 设计要点: -- **会话隔离**:通过构造函数注入 ``session_factory``,每次执行创建独立 db - 会话与适配器,避免 worker 进程长生命周期会话问题。 +- **无状态仓储**:通过构造函数注入无状态仓储端口实例(适配器内部通过 + ``_session_scope(tx)`` 按需获取 session,``tx`` 为 None 时自主创建并提交), + 避免长生命周期会话问题。 - **批量处理**:单次执行最多处理 ``batch_size`` 条,避免长事务锁竞争。 - **分布式锁**:``execute`` 通过 ``acquireAdvisoryLock`` 串行化多 worker 并发执行(lock_key=``scheduler:{class_name}``, TTL=300s),锁获取失败时 @@ -20,11 +21,6 @@ PENDING 状态的配对记录,通过聚合根方法 ``PairingApproval.expireIf from __future__ import annotations -from collections.abc import Callable -from contextlib import AbstractAsyncContextManager - -from sqlalchemy.ext.asyncio import AsyncSession - from yuxi.channels.contract.dtos.pairing import PairingStatus from yuxi.channels.contract.errors import DependencyError from yuxi.channels.contract.ports.driven.cache_port import CachePort @@ -48,11 +44,8 @@ class ChannelPairingExpirationHandler: batch_size: 单次执行最多扫描的记录数,默认 100。 依赖注入: - session_factory: 调用返回 ``AsyncSession`` 的异步上下文管理器工厂, - 通常为 ``pg_manager.get_async_session_context``。每次执行创建独立 - 会话,避免长生命周期会话问题。 - pairing_repo_factory: 配对仓储端口工厂,接收 ``AsyncSession`` 返回 - ``PairingRepositoryPort`` 实例。 + pairing_repo: 配对仓储端口实例(无状态,内部通过 + ``_session_scope(tx)`` 按需获取 session)。 cache_port: 缓存端口,用于获取分布式锁串行化多 worker 并发执行。 logger: 日志端口实例。 """ @@ -66,13 +59,11 @@ class ChannelPairingExpirationHandler: def __init__( self, - session_factory: Callable[[], AbstractAsyncContextManager[AsyncSession]], - pairing_repo_factory: Callable[[AsyncSession], PairingRepositoryPort], + pairing_repo: PairingRepositoryPort, cache_port: CachePort, logger: LoggerPort, ) -> None: - self._session_factory = session_factory - self._pairing_repo_factory = pairing_repo_factory + self._pairing_repo = pairing_repo self._cache_port = cache_port self._logger = logger @@ -109,11 +100,10 @@ class ChannelPairingExpirationHandler: 流程: 1. 从 ctx.payload 读取 batch_size。 - 2. 通过 session_factory 创建独立 session + 适配器。 - 3. 拉取 ``expires_at`` 早于当前时间的 PENDING 配对。 - 4. 重建 ``PairingApproval`` 聚合根并调用 ``expireIfOverdue``。 - 5. 返回 ``True`` 时持久化为 EXPIRED。 - 6. 记录结构化日志,返回 TaskResult(success=True, output={...})。 + 2. 拉取 ``expires_at`` 早于当前时间的 PENDING 配对。 + 3. 重建 ``PairingApproval`` 聚合根并调用 ``expireIfOverdue``。 + 4. 返回 ``True`` 时持久化为 EXPIRED。 + 5. 记录结构化日志,返回 TaskResult(success=True, output={...})。 """ batch_size: int = ctx.payload.get( "batch_size", @@ -122,37 +112,35 @@ class ChannelPairingExpirationHandler: now = utc_now_naive() try: - async with self._session_factory() as db: - pairing_repo = self._pairing_repo_factory(db) - records = await pairing_repo.listExpiredPendingPairings( - before=now, - limit=batch_size, + records = await self._pairing_repo.listExpiredPendingPairings( + before=now, + limit=batch_size, + ) + + if not records: + return TaskResult( + success=True, + output={"expired_count": 0, "scanned_count": 0}, ) - if not records: - return TaskResult( - success=True, - output={"expired_count": 0, "scanned_count": 0}, - ) - - expired_count = 0 - for record in records: - try: - approval = PairingApproval.fromRecord(record) - if approval.expireIfOverdue(): - await pairing_repo.updatePairingStatus( - record.pairing_id, - PairingStatus.EXPIRED, - expected_version=record.version, - updated_by="system", - ) - expired_count += 1 - except Exception as exc: - await self._logger.exception( - "配对过期标记失败,跳过", - exc_info=exc, - pairing_id=record.pairing_id, + expired_count = 0 + for record in records: + try: + approval = PairingApproval.fromRecord(record) + if approval.expireIfOverdue(): + await self._pairing_repo.updatePairingStatus( + record.pairing_id, + PairingStatus.EXPIRED, + expected_version=record.version, + updated_by="system", ) + expired_count += 1 + except Exception as exc: + await self._logger.exception( + "配对过期标记失败,跳过", + exc_info=exc, + pairing_id=record.pairing_id, + ) if expired_count > 0: await self._logger.info( diff --git a/backend/package/yuxi/channels/application/extension/scheduler_handlers/channel_pairing_terminal_cleanup_handler.py b/backend/package/yuxi/channels/application/extension/scheduler_handlers/channel_pairing_terminal_cleanup_handler.py index d4fec692..96fc871b 100644 --- a/backend/package/yuxi/channels/application/extension/scheduler_handlers/channel_pairing_terminal_cleanup_handler.py +++ b/backend/package/yuxi/channels/application/extension/scheduler_handlers/channel_pairing_terminal_cleanup_handler.py @@ -6,8 +6,9 @@ 设计要点: -- **会话隔离**:通过构造函数注入 ``session_factory``,每次执行创建独立 db - 会话与适配器,避免 worker 进程长生命周期会话问题。 +- **无状态仓储**:通过构造函数注入无状态仓储端口实例(适配器内部通过 + ``_session_scope(tx)`` 按需获取 session,``tx`` 为 None 时自主创建并提交), + 避免长生命周期会话问题。 - **批量处理**:单次执行最多清理 ``batch_size`` 条,避免长事务锁竞争。 - **分布式锁**:``execute`` 通过 ``acquireAdvisoryLock`` 串行化多 worker 并发执行(lock_key=``scheduler:{class_name}``, TTL=300s),锁获取失败时 @@ -20,12 +21,8 @@ from __future__ import annotations -from collections.abc import Callable -from contextlib import AbstractAsyncContextManager from datetime import UTC, datetime, timedelta -from sqlalchemy.ext.asyncio import AsyncSession - from yuxi.channels.contract.errors import DependencyError from yuxi.channels.contract.ports.driven.cache_port import CachePort from yuxi.channels.contract.ports.driven.logger_port import LoggerPort @@ -47,12 +44,9 @@ class ChannelPairingTerminalCleanupHandler: batch_size: 单次执行最多清理的记录数,默认 500。 依赖注入: - session_factory: 调用返回 ``AsyncSession`` 的异步上下文管理器工厂, - 通常为 ``pg_manager.get_async_session_context``。每次执行创建独立 - 会话,避免长生命周期会话问题。 - pairing_repo_factory: 配对仓储端口工厂,接收 ``AsyncSession`` 返回 - ``PairingRepositoryPort`` 实例。由 infrastructure 层装配具体适配器 - (如 ``SqlAlchemyPairingRepositoryAdapter``),应用层不直接依赖 + pairing_repo: 配对仓储端口实例。由 infrastructure 层装配具体适配器 + (如 ``ChannelPersistenceAdapter``),无状态(内部通过 + ``_session_scope(tx)`` 按需获取 session),应用层不直接依赖 适配器实现(INV-1 依赖方向合规)。 cache_port: 缓存端口,用于获取分布式锁串行化多 worker 并发执行。 logger: 日志端口实例。 @@ -65,13 +59,11 @@ class ChannelPairingTerminalCleanupHandler: def __init__( self, - session_factory: Callable[[], AbstractAsyncContextManager[AsyncSession]], - pairing_repo_factory: Callable[[AsyncSession], PairingRepositoryPort], + pairing_repo: PairingRepositoryPort, cache_port: CachePort, logger: LoggerPort, ) -> None: - self._session_factory = session_factory - self._pairing_repo_factory = pairing_repo_factory + self._pairing_repo = pairing_repo self._cache_port = cache_port self._logger = logger @@ -108,9 +100,8 @@ class ChannelPairingTerminalCleanupHandler: 流程: 1. 从 ctx.payload 读取 retention_days 与 batch_size。 2. 计算 before 截止时间。 - 3. 通过 session_factory 创建独立 session + 适配器。 - 4. 调用 cleanupOldTerminalPairings 清理终态配对记录。 - 5. 记录结构化日志,返回 TaskResult(success=True, output={...})。 + 3. 调用 cleanupOldTerminalPairings 清理终态配对记录。 + 4. 记录结构化日志,返回 TaskResult(success=True, output={...})。 """ retention_days: int = ctx.payload.get("retention_days", 30) batch_size: int = ctx.payload.get("batch_size", 500) @@ -118,13 +109,10 @@ class ChannelPairingTerminalCleanupHandler: before = datetime.now(UTC) - timedelta(days=retention_days) try: - async with self._session_factory() as db: - pairing_repo = self._pairing_repo_factory(db) - - cleaned_count = await pairing_repo.cleanupOldTerminalPairings( - before=before, - limit=batch_size, - ) + cleaned_count = await self._pairing_repo.cleanupOldTerminalPairings( + before=before, + limit=batch_size, + ) await self._logger.info( "终态配对记录物理清理完成", diff --git a/backend/package/yuxi/channels/application/extension/scheduler_handlers/channel_session_inactive_cleanup_handler.py b/backend/package/yuxi/channels/application/extension/scheduler_handlers/channel_session_inactive_cleanup_handler.py index 7cb7c595..e63039e2 100644 --- a/backend/package/yuxi/channels/application/extension/scheduler_handlers/channel_session_inactive_cleanup_handler.py +++ b/backend/package/yuxi/channels/application/extension/scheduler_handlers/channel_session_inactive_cleanup_handler.py @@ -6,8 +6,9 @@ 设计要点: -- **会话隔离**:通过构造函数注入 ``session_factory``,每次执行创建独立 db - 会话与适配器,避免 worker 进程长生命周期会话问题。 +- **无状态仓储**:通过构造函数注入无状态仓储端口实例(适配器内部通过 + ``_session_scope(tx)`` 按需获取 session,``tx`` 为 None 时自主创建并提交), + 避免长生命周期会话问题。 - **批量处理**:单次执行最多清理 ``batch_size`` 条,避免长事务锁竞争。 - **分布式锁**:``execute`` 通过 ``acquireAdvisoryLock`` 串行化多 worker 并发执行(lock_key=``scheduler:{class_name}``, TTL=300s),锁获取失败时 @@ -20,12 +21,8 @@ from __future__ import annotations -from collections.abc import Callable -from contextlib import AbstractAsyncContextManager from datetime import UTC, datetime, timedelta -from sqlalchemy.ext.asyncio import AsyncSession - from yuxi.channels.contract.errors import DependencyError from yuxi.channels.contract.ports.driven.cache_port import CachePort from yuxi.channels.contract.ports.driven.channel_session_repository_port import ( @@ -50,13 +47,10 @@ class ChannelSessionInactiveCleanupHandler: batch_size: 单次执行最多清理的会话数,默认 100。 依赖注入: - session_factory: 调用返回 ``AsyncSession`` 的异步上下文管理器工厂, - 通常为 ``pg_manager.get_async_session_context``。每次执行创建独立 - 会话,避免长生命周期会话问题。 - session_repo_factory: 渠道会话仓储端口工厂,接收 ``AsyncSession`` 返回 - ``ChannelSessionRepositoryPort`` 实例。由 infrastructure 层装配具体 - 适配器(如 ``SqlAlchemyChannelSessionRepositoryAdapter``),应用层 - 不直接依赖适配器实现(INV-1 依赖方向合规)。 + session_repo: 渠道会话仓储端口实例。由 infrastructure 层装配具体 + 适配器(如 ``ChannelPersistenceAdapter``),无状态(内部通过 + ``_session_scope(tx)`` 按需获取 session),应用层不直接依赖 + 适配器实现(INV-1 依赖方向合规)。 cache_port: 缓存端口,用于获取分布式锁串行化多 worker 并发执行。 logger: 日志端口实例。 """ @@ -68,14 +62,12 @@ class ChannelSessionInactiveCleanupHandler: def __init__( self, - session_factory: Callable[[], AbstractAsyncContextManager[AsyncSession]], - session_repo_factory: Callable[[AsyncSession], ChannelSessionRepositoryPort], + session_repo: ChannelSessionRepositoryPort, cache_port: CachePort, logger: LoggerPort, realtime_metrics: RealtimeMetricsPort | None = None, ) -> None: - self._session_factory = session_factory - self._session_repo_factory = session_repo_factory + self._session_repo = session_repo self._cache_port = cache_port self._logger = logger self._realtime_metrics = realtime_metrics @@ -113,10 +105,10 @@ class ChannelSessionInactiveCleanupHandler: 流程: 1. 从 ctx.payload 读取 inactive_threshold_minutes 与 batch_size。 2. 计算 inactive_before 截止时间。 - 3. 通过 session_factory 创建独立 session + 适配器。 - 4. 调用 listInactiveTemporarySessions 查询待清理会话。 - 5. 无会话时返回 success(count=0)。 - 6. 提取 session_id 列表,调用 cleanupInactiveSessions 批量关闭。 + 3. 调用 listInactiveTemporarySessions 查询待清理会话。 + 4. 无会话时返回 success(count=0)。 + 5. 提取 session_id 列表,调用 cleanupInactiveSessions 批量关闭。 + 6. 释放实时指标中的活跃会话(DSB-REALTIME)。 7. 记录结构化日志,返回 TaskResult(success=True, output={...})。 """ inactive_threshold_minutes: int = ctx.payload.get("inactive_threshold_minutes", 60) @@ -125,24 +117,21 @@ class ChannelSessionInactiveCleanupHandler: inactive_before = datetime.now(UTC) - timedelta(minutes=inactive_threshold_minutes) try: - async with self._session_factory() as db: - session_repo = self._session_repo_factory(db) - - sessions = await session_repo.listInactiveTemporarySessions( - inactive_before=inactive_before, - limit=batch_size, + sessions = await self._session_repo.listInactiveTemporarySessions( + inactive_before=inactive_before, + limit=batch_size, + ) + if not sessions: + return TaskResult( + success=True, + output={ + "cleaned_count": 0, + "inactive_threshold_minutes": inactive_threshold_minutes, + }, ) - if not sessions: - return TaskResult( - success=True, - output={ - "cleaned_count": 0, - "inactive_threshold_minutes": inactive_threshold_minutes, - }, - ) - session_ids = [s.session_id for s in sessions] - cleaned_count = await session_repo.cleanupInactiveSessions(session_ids) + session_ids = [s.session_id for s in sessions] + cleaned_count = await self._session_repo.cleanupInactiveSessions(session_ids) # 释放实时指标中的活跃会话(DSB-REALTIME),避免清理后的会话 # 仍计入 active_sessions / active_conversations。 diff --git a/backend/package/yuxi/channels/application/health/health_aggregator.py b/backend/package/yuxi/channels/application/health/health_aggregator.py index 0737638e..74ae7212 100644 --- a/backend/package/yuxi/channels/application/health/health_aggregator.py +++ b/backend/package/yuxi/channels/application/health/health_aggregator.py @@ -292,12 +292,6 @@ class HealthAggregator: degraded_component_details=self._buildDegradedComponentDetails([self.HEALTH_CHECK_TIMEOUT_COMPONENT]), status_message="系统部分降级:健康检查超时,聚合结果可能不完整,建议稍后重试或导出诊断包排查。", ) - finally: - # 健康检查在应用级共享 session 上执行 ping / getConnectionPoolStatus / - # listChannelAccounts,SQLAlchemy 2.0 autobegin 会开启隐式事务并 - # 持有连接直至显式提交。每轮检查结束(含超时/异常路径)调用 - # releaseSession 归还连接,避免健康检查反复触发导致连接池耗尽。 - await self._releasePersistenceSession() if not query.with_probe: # 缓存写入前序列化为 JSON-safe dict,避免 dataclass 被 @@ -311,30 +305,6 @@ class HealthAggregator: return snapshot - async def _releasePersistenceSession(self) -> None: - """释放持久化 session 占用的连接。 - - ``persistence_port`` 实际注入的是 ``PersistencePort``(同时满足 - ``PersistenceHealthPort`` 与 ``ChannelAccountRepositoryPort``), - 调用 ``releaseSession`` 归还应用级共享 ``AsyncSession`` 占用的连接 - (SQLAlchemy 2.0 autobegin 隐式事务在只读查询后持有连接)。 - - 释放失败仅记 WARN 日志,不向上抛出(健康检查不应因连接归还失败 - 而中断返回快照)。``persistence_port`` 未实现 ``releaseSession`` - 时 no-op(测试场景注入的 mock 可能不实现该方法)。 - """ - releaser = getattr(self._persistence_port, "releaseSession", None) - if releaser is None: - return - try: - await releaser() - except Exception as exc: - if self._logger is not None: - await self._logger.warn( - "健康检查释放会话连接失败", - error=str(exc), - ) - async def _aggregate(self, query: HealthQuery) -> HealthSnapshot: """执行健康聚合逻辑(不含缓存读写,由 ``checkHealth`` 包裹超时)。 diff --git a/backend/package/yuxi/channels/application/pipeline/outbound/deliver_stage.py b/backend/package/yuxi/channels/application/pipeline/outbound/deliver_stage.py index 4df16178..116d3a9f 100644 --- a/backend/package/yuxi/channels/application/pipeline/outbound/deliver_stage.py +++ b/backend/package/yuxi/channels/application/pipeline/outbound/deliver_stage.py @@ -494,7 +494,6 @@ class DeliverStage: latest_aggregate.partial_failure = True latest_aggregate.last_error = f"partial failure on parts: {failed_parts}" latest_aggregate.updated_at = datetime.now(UTC) - latest_aggregate.version += 1 await self.persistence_port.updateOutboxEntry( latest_aggregate, expected_status=OutboxStatus.SENT_UNCONFIRMED, diff --git a/backend/package/yuxi/channels/application/pipeline/outbound/load_build_stage.py b/backend/package/yuxi/channels/application/pipeline/outbound/load_build_stage.py index eb54e075..b81179b3 100644 --- a/backend/package/yuxi/channels/application/pipeline/outbound/load_build_stage.py +++ b/backend/package/yuxi/channels/application/pipeline/outbound/load_build_stage.py @@ -1,18 +1,23 @@ """出站管道 load-build 阶段。 从上下文构建 OutboundPayload,关联 Agent 运行 ID 并携带流式分块,供下游 -阶段格式化与投递。本阶段无领域服务调用,仅做数据装配。 +阶段格式化与投递。持久化模式下,AgentRun 响应内容由本阶段通过阻塞消费 +streamAgentRun 事件流加载(stream_chunk_stage 在持久化模式下被跳过)。 """ from __future__ import annotations from collections.abc import Callable +from contextlib import aclosing from typing import Any from yuxi.channels.application.context.outbound_context import OutboundContext +from yuxi.channels.contract.dtos.agent_run import AgentRunId from yuxi.channels.contract.dtos.common import Attachment +from yuxi.channels.contract.dtos.option import Some from yuxi.channels.contract.dtos.outbound import OutboundPayload, RichMessageFields from yuxi.channels.contract.plugin.extension_point import FailureStrategy +from yuxi.channels.contract.ports.driven.agent_run_port import AgentRunPort __all__ = ["LoadBuildStage"] @@ -21,15 +26,18 @@ class LoadBuildStage: """出站管道 load-build 阶段。 职责:构建出站负载 OutboundPayload,关联 Agent 运行 ID 与流式分块, - 携带富消息字段(FR-08)与附件列表(FR-48)。 + 携带富消息字段(FR-08)与附件列表(FR-48)。持久化模式下负责加载 + AgentRun 完整响应内容(stream_chunk_stage 被跳过,内容加载职责由 + 本阶段承接)。 关联 FR:FR-13(流式分块装配)、FR-08(富消息字段填充)、 FR-48(附件装配)。 StageContract: - id="load-build" - - reads=("agent_run_id", "stream_chunks", "rich_message", "attachments") - - writes=("outbound_payload",) + - reads=("agent_run_id", "stream_chunks", "rich_message", "attachments", + "delivery_mode") + - writes=("outbound_payload", "stream_chunks") - idempotent=True - thread_safe=True - failure=TERMINATE @@ -38,30 +46,45 @@ class LoadBuildStage: """ id: str = "load-build" - reads: tuple[str, ...] = ("agent_run_id", "stream_chunks", "rich_message", "attachments") - writes: tuple[str, ...] = ("outbound_payload",) + reads: tuple[str, ...] = ( + "agent_run_id", + "stream_chunks", + "rich_message", + "attachments", + "delivery_mode", + ) + writes: tuple[str, ...] = ("outbound_payload", "stream_chunks") idempotent: bool = True thread_safe: bool = True failure: FailureStrategy = FailureStrategy.TERMINATE compensate: str | None = None condition: Callable[[Any], bool] | None = None - def __init__(self) -> None: - """初始化 load-build 阶段。""" - pass + def __init__(self, agent_run_port: AgentRunPort) -> None: + """初始化 load-build 阶段。 + + 参数: + agent_run_port: Agent 运行被驱动端口,持久化模式下通过 + ``streamAgentRun`` 阻塞消费事件流等待 AgentRun 完成, + 再通过 ``getAgentRunFinalOutput`` 获取完整输出文本。 + """ + self._agent_run_port = agent_run_port async def process(self, context: OutboundContext) -> bool: """构建出站负载。 处理步骤: - 1. 从 ctx.stream_chunks 构造分块元组。 - 2. 从 ctx.rich_message 构造富消息字段(FR-08)。 - 3. 装配附件列表(FR-48): + 1. 持久化模式下,若 agent_run_id 非空且 stream_chunks 为空, + 通过 streamAgentRun 阻塞消费至 AgentRun 完成,再调用 + getAgentRunFinalOutput 获取完整文本填充 stream_chunks。 + 2. 从 ctx.stream_chunks 构造分块元组。 + 3. 从 ctx.rich_message 构造富消息字段(FR-08)。 + 4. 装配附件列表(FR-48): - ctx.attachments 直接并入 - rich_message.image_url 转换为 Attachment(type="image") 并入 - rich_message.video_url 转换为 Attachment(type="video") 并入 - 4. 构造 OutboundPayload,关联 agent_run_id。 - 5. 设置 ctx.outbound_payload。 + 5. 构造 OutboundPayload,关联 agent_run_id。 + 6. 设置 ctx.outbound_payload。 参数: context: 出站管道上下文。 @@ -71,6 +94,17 @@ class LoadBuildStage: 抛出:无 """ + # 持久化模式下,AgentRun 响应内容尚未加载(stream_chunk_stage 被 + # condition 跳过)。阻塞消费事件流等待 AgentRun 完成后加载完整输出, + # 由下游 outbox-persist/deliver 投递。流式模式下 stream_chunk_stage + # 负责加载+投递,此处不介入。 + if ( + context.delivery_mode == "persistent" + and context.agent_run_id + and not context.stream_chunks + ): + await self._loadAgentRunOutput(context) + rich_message_fields = None attachments: list[Attachment] = list(context.attachments) @@ -90,3 +124,27 @@ class LoadBuildStage: attachments=tuple(attachments), ) return True + + async def _loadAgentRunOutput(self, context: OutboundContext) -> None: + """持久化模式下加载 AgentRun 完整输出。 + + 两步: + 1. 消费 ``streamAgentRun`` 事件流阻塞至 AgentRun 完成(``end`` + 事件或 DB 终态兜底触发迭代结束)。 + 2. 调用 ``getAgentRunFinalOutput`` 一次性获取完整文本,复用 + 已有的 payload 提取逻辑(避免重复解析 StreamEvent payload + 嵌套结构)。 + + 参数: + context: 出站管道上下文,读取 ``agent_run_id``,写入 + ``stream_chunks``。 + """ + run_id = AgentRunId(value=context.agent_run_id) + # 步骤 1:阻塞消费至完成(不提取内容,仅等待 end 信号) + async with aclosing(self._agent_run_port.streamAgentRun(run_id)) as aiterator: + async for _ in aiterator: + pass + # 步骤 2:一次性获取完整文本 + result = await self._agent_run_port.getAgentRunFinalOutput(run_id) + if isinstance(result, Some): + context.stream_chunks.append(result.value) diff --git a/backend/package/yuxi/channels/application/pipeline/outbound/outbound_pipeline.py b/backend/package/yuxi/channels/application/pipeline/outbound/outbound_pipeline.py index 029a6263..7bc823a3 100644 --- a/backend/package/yuxi/channels/application/pipeline/outbound/outbound_pipeline.py +++ b/backend/package/yuxi/channels/application/pipeline/outbound/outbound_pipeline.py @@ -220,7 +220,7 @@ class OutboundPipeline(Pipeline): conversation_port=conversation_port, logger=logger, ) - load_build_stage = LoadBuildStage() + load_build_stage = LoadBuildStage(agent_run_port=agent_run_port) capability_verify_stage = CapabilityVerifyStage( capability_verifier=capability_verifier, plugin_registry=plugin_registry, @@ -244,6 +244,7 @@ class OutboundPipeline(Pipeline): streaming_adapter_registry=streaming_adapter_registry, config_port=config_port, agent_run_port=agent_run_port, + outbound_adapter_registry=outbound_adapter_registry, logger=logger, ) truncation_check_stage = TruncationCheckStage( diff --git a/backend/package/yuxi/channels/application/pipeline/outbound/outbox_mark_failed_stage.py b/backend/package/yuxi/channels/application/pipeline/outbound/outbox_mark_failed_stage.py index adf55317..64d34fab 100644 --- a/backend/package/yuxi/channels/application/pipeline/outbound/outbox_mark_failed_stage.py +++ b/backend/package/yuxi/channels/application/pipeline/outbound/outbox_mark_failed_stage.py @@ -84,7 +84,7 @@ class OutboxMarkFailed: 处理步骤: 1. 从 ctx.outbox_entry 获取 outbox_id。 2. **重新查询最新 outbox 条目**(补偿幂等性保证):deliver 阶段 - 可能已通过 markSentUnconfirmed 递增 version 并持久化, + 可能已通过 updateOutboxEntry 递增 DB version 并持久化, ``context.outbox_entry`` 的 version 可能已过期,直接用会 导致乐观锁 ConflictError。重新查询保证读到最新 version。 3. 终态检查:SENT / FAILED / DEAD / SUPPRESSED 等终态条目 @@ -111,8 +111,8 @@ class OutboxMarkFailed: return True # 补偿幂等性保证:重新查询最新快照。deliver 阶段在发送前会调用 - # markSentUnconfirmed + updateOutboxEntry 递增 version,若发送失败 - # 触发补偿时 context.outbox_entry.version 仍是旧值(deliver 失败路径 + # updateOutboxEntry 递增 DB version,若发送失败触发补偿时 + # context.outbox_entry.version 仍是旧值(deliver 失败路径 # 不保证同步最新 DTO),直接用旧 version 更新会触发乐观锁 # ConflictError。重新查询确保读到 DB 当前 version。 latest = await self.persistence_port.getOutboxEntry(dto.outbox_id) diff --git a/backend/package/yuxi/channels/application/pipeline/outbound/prefix_stage.py b/backend/package/yuxi/channels/application/pipeline/outbound/prefix_stage.py index 4db5f395..b607d2a3 100644 --- a/backend/package/yuxi/channels/application/pipeline/outbound/prefix_stage.py +++ b/backend/package/yuxi/channels/application/pipeline/outbound/prefix_stage.py @@ -40,7 +40,7 @@ class PrefixStage: writes: tuple[str, ...] = ("final_message",) idempotent: bool = True thread_safe: bool = True - failure: FailureStrategy = FailureStrategy.SKIP + failure: FailureStrategy = FailureStrategy.TERMINATE compensate: str | None = None # 流式模式下 content 由 stream-chunk 阶段独立投递,不构造 FinalMessage, # 跳过 prefix 避免对流式尚未到达的 content 做非空校验 diff --git a/backend/package/yuxi/channels/application/pipeline/outbound/stream_chunk_stage.py b/backend/package/yuxi/channels/application/pipeline/outbound/stream_chunk_stage.py index 54079824..62079723 100644 --- a/backend/package/yuxi/channels/application/pipeline/outbound/stream_chunk_stage.py +++ b/backend/package/yuxi/channels/application/pipeline/outbound/stream_chunk_stage.py @@ -19,26 +19,36 @@ TTL 安全阀(FR-13 / AC-62):累计耗时超过 ``streaming_ttl_ms`` 时 发送分块,标记 ``stream_aborted_at_chunk`` 并降级为持久化模式,由 typing-stop 阶段强制结束流式会话(``endStreaming``),deliver 阶段检测中断标记后调用 ``sendMessageContinuation`` 续发剩余内容(H-2)。 + +降级时重新格式化:TTL 超时或异常降级为持久化模式后,``format_stage`` 在 +流式模式下设置的空 content ``formatted_message`` 已过期。本阶段在降级时 +用已消费的 ``stream_chunks`` 重新调用 ``formatOutbound`` 更新 +``formatted_message``,确保后续 ``outbox_persist``/``deliver`` 阶段读到 +非空 content。 """ from __future__ import annotations import asyncio import time -from collections.abc import AsyncIterator, Callable +from collections.abc import AsyncIterator, Callable, Iterator from contextlib import aclosing from typing import Any from yuxi.channels.application.context.outbound_context import OutboundContext from yuxi.channels.contract.dtos.agent_run import AgentRunId from yuxi.channels.contract.dtos.channel import ChannelType +from yuxi.channels.contract.dtos.common import MessageContent, MessageFormat from yuxi.channels.contract.dtos.config import ConfigScope +from yuxi.channels.contract.dtos.outbound import FormattedMessage from yuxi.channels.contract.dtos.streaming import ( ChunkResult, StreamChunk, StreamingCompleted, ) +from yuxi.channels.contract.dtos.stream_event import StreamEvent from yuxi.channels.contract.errors.domain import ChannelDegradedError +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.extension_point import FailureStrategy from yuxi.channels.contract.ports.driven.agent_run_port import AgentRunPort @@ -48,19 +58,58 @@ from yuxi.channels.contract.ports.driven.logger_port import LoggerPort __all__ = ["StreamChunkStage"] +def _extractTextFromStreamEvent(event: StreamEvent) -> Iterator[str]: + """从 StreamEvent 提取文本内容。 + + 与 ``AgentRunAdapter.getAgentRunFinalOutput`` 的提取逻辑一致: + 1. 仅处理 ``event_type == "messages"`` 的事件,跳过 metadata/custom/ + error/end 等非内容事件。 + 2. 穿透两层 payload:``event.payload``(envelope)→ + ``envelope["payload"]``(inner)→ ``inner["items"]``。 + 3. 对每个 item 优先取 ``stream_event.content``(语义增量), + 回退 ``response``(直接文本),避免重复拼接。 + + 参数: + event: StreamEvent DTO。 + + 生成: + 文本内容字符串。 + """ + if event.event_type != "messages": + return + envelope = event.payload or {} + inner_payload = envelope.get("payload") or {} + items = inner_payload.get("items") or [] + for item in items: + if not isinstance(item, dict): + continue + stream_event = item.get("stream_event") + if isinstance(stream_event, dict): + content = stream_event.get("content") + if isinstance(content, str) and content: + yield content + continue + response = item.get("response") + if isinstance(response, str) and response: + yield response + + class StreamChunkStage: """出站管道 stream-chunk 阶段。 职责:流式分块投递(FR-13),消费 Agent Run 事件流并按序将分块 - 发送至渠道侧,实现实时流式输出(AC-15)。 + 发送至渠道侧,实现实时流式输出(AC-15)。降级时重新格式化 + ``formatted_message``,确保持久化路径读到非空 content。 关联 FR:FR-13(流式输出)。 StageContract: - id="stream-chunk" - - reads=("channel_type", "account_id", "peer_id", "agent_run_id", "stream_chunks", "delivery_mode", "trace_id") + - reads=("channel_type", "account_id", "peer_id", "agent_run_id", + "stream_chunks", "delivery_mode", "trace_id", "formatted_message") - writes=("chunk_results", "stream_chunks", "streaming_completed", - "delivery_mode", "degraded", "degraded_reason") + "delivery_mode", "degraded", "degraded_reason", + "stream_aborted_at_chunk", "formatted_message") - idempotent=False - thread_safe=False - failure=DEGRADE @@ -77,6 +126,7 @@ class StreamChunkStage: "stream_chunks", "delivery_mode", "trace_id", + "formatted_message", ) writes: tuple[str, ...] = ( "chunk_results", @@ -86,6 +136,7 @@ class StreamChunkStage: "degraded", "degraded_reason", "stream_aborted_at_chunk", + "formatted_message", ) idempotent: bool = False thread_safe: bool = False @@ -98,6 +149,7 @@ class StreamChunkStage: streaming_adapter_registry: dict[ChannelType, StreamingAdapter], config_port: ConfigPort, agent_run_port: AgentRunPort, + outbound_adapter_registry: dict[ChannelType, OutboundAdapter], logger: LoggerPort | None = None, ) -> None: """初始化 stream-chunk 阶段。 @@ -106,12 +158,15 @@ class StreamChunkStage: streaming_adapter_registry: 流式适配器注册表,按渠道类型索引。 config_port: 配置被驱动端口,用于读取最小分块间隔与 TTL。 agent_run_port: Agent 运行被驱动端口,用于流式消费 Agent 运行事件。 + outbound_adapter_registry: 出站适配器注册表,降级时调用 + ``formatOutbound`` 重新格式化 ``formatted_message``。 logger: 可选日志端口,用于 TTL 安全阀告警。为 ``None`` 时跳过 告警日志输出。 """ self.streaming_adapter_registry = streaming_adapter_registry self.config_port = config_port self.agent_run_port = agent_run_port + self.outbound_adapter_registry = outbound_adapter_registry self.logger = logger async def process(self, context: OutboundContext) -> bool: @@ -123,12 +178,13 @@ class StreamChunkStage: 3. 消费 Agent Run 事件流(或预填充分块),逐块投递至渠道侧 (AC-15:用户看到逐步更新)。 4. 累计耗时超过 TTL 时停止发送,标记 stream_aborted_at_chunk 并 - 降级为持久化模式,记录告警日志(AC-62,H-2)。 + 降级为持久化模式,重新格式化 formatted_message,记录告警日志 + (AC-62,H-2)。 5. 构造 StreamingCompleted,设置 ctx.streaming_completed。 适配器不存在或投递异常时,将 delivery_mode 降级为持久化模式并显式 - 标记 context.degraded,由后续持久化投递阶段(outbox-persist / - deliver)继续执行。 + 标记 context.degraded,重新格式化 formatted_message,由后续持久化 + 投递阶段(outbox-persist / deliver)继续执行。 参数: context: 出站管道上下文。 @@ -147,6 +203,7 @@ class StreamChunkStage: context.channel_type, trace_id=context.trace_id, ) + await self._reformatAfterDegrade(context) return True streaming_config = await self.config_port.getStreamingConfig(ConfigScope.ACCOUNT, context.account_id) @@ -177,6 +234,9 @@ class StreamChunkStage: # sendMessageContinuation 续发剩余内容(H-2) context.stream_aborted_at_chunk = len(results) context.delivery_mode = "persistent" + # 降级后重新格式化 formatted_message,确保后续 + # outbox-persist/deliver 阶段读到非空 content + await self._reformatAfterDegrade(context) break if results and min_interval_s > 0: @@ -198,6 +258,9 @@ class StreamChunkStage: # sendMessageContinuation 续发剩余内容(H-2) context.stream_aborted_at_chunk = len(results) context.delivery_mode = "persistent" + # 降级后重新格式化 formatted_message(在抛出前执行,因 DEGRADE + # 策略会 continue 后续阶段,需确保 formatted_message 已更新) + await self._reformatAfterDegrade(context) raise ChannelDegradedError( context.channel_type, trace_id=context.trace_id, @@ -222,6 +285,10 @@ class StreamChunkStage: - ``agent_run_id`` 为空时(如管理员消息),遍历上游预填充的 ``stream_chunks`` 列表。 + 从 ``StreamEvent`` 提取文本内容时,按 ``event_type == "messages"`` + 过滤并穿透两层 payload(envelope → inner → items),与 + ``getAgentRunFinalOutput`` 的提取逻辑一致。 + 参数: context: 出站管道上下文。 @@ -232,12 +299,50 @@ class StreamChunkStage: run_id = AgentRunId(value=context.agent_run_id) aiterator = self.agent_run_port.streamAgentRun(run_id) try: - async for chunk in aiterator: - content = getattr(chunk, "content", str(chunk)) - context.stream_chunks.append(content) - yield content + async for event in aiterator: + for content in _extractTextFromStreamEvent(event): + context.stream_chunks.append(content) + yield content finally: await aiterator.aclose() else: for content in context.stream_chunks: yield content + + async def _reformatAfterDegrade(self, context: OutboundContext) -> None: + """降级后重新格式化 ``formatted_message``。 + + 流式模式下 ``format_stage`` 先执行时 ``stream_chunks`` 为空, + ``formatted_message.content`` 为空字符串。降级为持久化模式后, + 用已消费的 ``stream_chunks`` 重新调用 ``formatOutbound`` 更新 + ``formatted_message``,确保后续 ``outbox_persist``/``deliver`` + 阶段读到非空 content 与正确的 ``rich_message``。 + + 参数: + context: 出站管道上下文。 + """ + content = "".join(context.stream_chunks) + if not content: + return + adapter = self.outbound_adapter_registry.get(context.channel_type) + if adapter is None: + # 理论上不会发生:所有渠道均注册出站适配器。直接构造 TEXT + # 格式的 formatted_message 保证管道不中断。 + context.formatted_message = FormattedMessage( + content=content, + format=MessageFormat.TEXT, + attachments=context.formatted_message.attachments + if context.formatted_message is not None + else (), + ) + return + message_content = MessageContent(text=content, format=MessageFormat.TEXT) + formatted = await adapter.formatOutbound(message_content) + context.formatted_message = FormattedMessage( + content=formatted.content, + format=formatted.format, + rich_message=formatted.rich_message, + attachments=context.formatted_message.attachments + if context.formatted_message is not None + else (), + ) diff --git a/backend/package/yuxi/channels/application/transport/manager.py b/backend/package/yuxi/channels/application/transport/manager.py index 1679a75a..97405c24 100644 --- a/backend/package/yuxi/channels/application/transport/manager.py +++ b/backend/package/yuxi/channels/application/transport/manager.py @@ -438,30 +438,31 @@ class TransportManager: source=source, ) - started = False + # 先持久化插件运行态为 running(FR-32 诊断字段,best-effort 写入), + # 必须在 start_account 之前调用:start_account 内部通过 asyncio.create_task + # 调度的 _runAccountLoop 后台任务会使用同一共享 AsyncSession 调用 + # getChannelAccount,若 updatePluginStatus 与 task 并发执行会触发 + # SQLAlchemy AsyncSession 并发访问异常(该异常在 channel_persistence_adapter + # 的 except SQLAlchemyError 分支被静默翻译为 DependencyError,无原始异常日志)。 + # 提前完成 updatePluginStatus 的 commit 可确保 task 启动时 session 处于干净状态。 + # plugin_status 为诊断字段,即使后续 start_account 因 circuit breaker open + # 等原因未真正启动 task,状态轻微不一致可接受(下次状态变化时纠正)。 + await self._touchPluginStatus(channel_type, account_id, "running", trace_id) + if transport_mode == "pull": 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) - started = True elif transport_mode == "stream": if stream_adapter is not None and self._stream_worker is not None: await self._stream_worker.start_account(channel_type, account_id, stream_adapter) - started = True else: # Task 11.2: both 模式优先 Stream,Puller 作为降级。 # Stream 适配器可用时仅启动 Stream(Stream 健康时不 poll); # Stream 适配器不可用时降级启动 Puller。 if stream_adapter is not None and self._stream_worker is not None: await self._stream_worker.start_account(channel_type, account_id, stream_adapter) - started = True 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) - started = True - - # 传输任务成功启动后持久化插件运行态(FR-32)。诊断字段,写入失败 - # 仅告警不中止(与 last_health_check_at 一致的 best-effort 语义)。 - if started: - await self._touchPluginStatus(channel_type, account_id, "running", trace_id) async def _restoreOnlineAccounts(self, trace_id: str) -> None: """重启恢复:扫描 DB 中 ACTIVE 状态账号,启动传输任务。 diff --git a/backend/package/yuxi/channels/application/usecase/identity_merge_service.py b/backend/package/yuxi/channels/application/usecase/identity_merge_service.py index 7a5b5441..c5223607 100644 --- a/backend/package/yuxi/channels/application/usecase/identity_merge_service.py +++ b/backend/package/yuxi/channels/application/usecase/identity_merge_service.py @@ -250,15 +250,11 @@ class IdentityMergeService: f"identity {cmd.merged_identity_id} is not pending review", ) - # 3. 记录 DB 版本(merge / clearPendingReview / unbindUser 仅递增内存 version) + # 3. 记录 DB 版本(merge 仅递增内存 version,持久化时用 DB 版本校验) canonical_db_version = canonical.version - # 4. 执行合并 + 清除 pending_review + 清除 sibling 的 user_id(M-7) - # 清除 user_id 与 _clearSiblingAndMigrateSessions 对齐,避免 sibling - # 仍被 listUserIdentitiesByUserId 返回导致后续 bindUserToSession 重复发现 + # 4. 执行合并(canonical) canonical.merge(sibling, trigger="approveMerge", operator_id=operator.user_id) - sibling.clearPendingReview() - sibling.unbindUser() # 5. 持久化 canonical 的 bindings/merged_from(乐观锁,expected_version 用 DB 版本) await self._user_identity_repo.updateUserIdentityBindings( @@ -270,10 +266,22 @@ class IdentityMergeService: tx=tx, ) - # 6. 持久化 sibling 的 pending_review 清除与 user_id 清除(乐观锁) - sibling_updated_dto = _aggregate_to_dto(sibling, sibling_dto) + # 6. 持久化 sibling 的状态变更:clearPendingReview 与 unbindUser 拆分为两次持久化。 + # updateUserIdentity 适配器以 ``identity.version - 1`` 作为期望旧版本号 + # (假设单次状态转换),多次状态转换需拆分持久化并在中间重建聚合根 + # 同步 DB version,否则乐观锁校验失败(M+2-1=M+1 ≠ DB M)。 + sibling.clearPendingReview() + sibling_dto_after = _aggregate_to_dto(sibling, sibling_dto) + updated_sibling_dto = await self._user_identity_repo.updateUserIdentity( + sibling_dto_after, + operator_id=operator.user_id, + tx=tx, + ) + sibling = UserIdentityAggregate.from_dto(updated_sibling_dto) + sibling.unbindUser() + sibling_dto_after = _aggregate_to_dto(sibling, updated_sibling_dto) await self._user_identity_repo.updateUserIdentity( - sibling_updated_dto, + sibling_dto_after, operator_id=operator.user_id, tx=tx, ) diff --git a/backend/package/yuxi/channels/contract/ports/driven/aggregate.py b/backend/package/yuxi/channels/contract/ports/driven/aggregate.py index facb6740..ca9f0360 100644 --- a/backend/package/yuxi/channels/contract/ports/driven/aggregate.py +++ b/backend/package/yuxi/channels/contract/ports/driven/aggregate.py @@ -62,15 +62,18 @@ class DrivenAdapters: 聚合 11 个被驱动适配器实例(含 1 个单实例 ``identity_resolver``)与 22 个渠道插件适配器列表(含 ``channel_context_providers`` 端口列表), - 供框架层与应用服务层统一注入。需要共享 ``AsyncSession`` 的适配器 - (``persistence`` / ``conversation`` / ``transaction``)在 factory 中共享同一 ``db``;无状态适配器 + 供框架层与应用服务层统一注入。``persistence`` 为无状态适配器(注入 + ``session_factory``,通过 ``_session_scope(tx)`` 按需获取 session,不持有 + 请求级 ``db``);``conversation`` / ``transaction`` 为请求级事务边界适配器, + 在 factory 中共享同一 ``db`` 会话以保证事务一致性;无状态适配器 使用全局实例;``identity_resolver`` 默认为 ``None``,由插件注册时注入。 其余 22 类渠道适配器(inbound/outbound/status 等)默认为空列表,由插件 通过 ``PluginHost.registerAdapter`` 注入,``create_channel_use_cases`` 从 ``PluginRegistry`` 读取后填充管道注册表。 字段: - persistence: 持久化适配器(PersistencePort)。 + persistence: 持久化适配器(PersistencePort),无状态(注入 + ``session_factory``,通过 ``_session_scope(tx)`` 按需获取 session)。 conversation: 会话适配器(ConversationPort)。 agent_run: Agent 运行适配器(AgentRunPort)。 queue: 任务队列适配器(QueuePort)。 @@ -162,14 +165,16 @@ class DrivenAdapters: CloseablePort)`` 显式判断是否实现关闭契约(INV-3),仅对实现方 调用 ``aclose`` 释放资源。 - ``ChannelPersistenceAdapter`` 持有共享 ``AsyncSession`` 并实现 - ``CloseablePort``,关闭它即释放 persistence / conversation / transaction - 三者共享的会话;``ConversationAdapter`` 与 ``SqlAlchemyTransactionAdapter`` - 不实现 ``CloseablePort``(共享 session 由 persistence 统一关闭,避免 - 重复关闭)。``AgentRunAdapter`` 与 ``ARQQueueAdapter`` 不持有 ``db``, - 每次调用内部获取临时会话,同样不实现 ``CloseablePort``。 + ``ChannelPersistenceAdapter`` 为无状态适配器(注入 ``session_factory``, + 不持有请求级 ``db``),不再实现 ``CloseablePort``,关闭时跳过。 + ``ConversationAdapter`` 与 ``SqlAlchemyTransactionAdapter`` 为请求级 + 事务边界适配器,共享同一 ``db`` 会话,但其生命周期由请求级框架管理 + (请求结束时由调用方 ``db.close()``),不实现 ``CloseablePort``。 + ``AgentRunAdapter`` 与 ``ARQQueueAdapter`` 不持有 ``db``,每次调用 + 内部获取临时会话,同样不实现 ``CloseablePort``。 未遍历的字段说明: + - ``persistence``:无状态适配器,不持有需释放资源。 - ``config`` / ``cache`` / ``logger`` / ``tracer`` / ``masking``: 无状态或由框架管理生命周期,无需关闭。 - ``identity_resolver`` / ``service_account`` / ``agent_access_config``: @@ -185,7 +190,6 @@ class DrivenAdapters: """ closed: set[int] = set() for adapter in ( - self.persistence, self.conversation, self.agent_run, self.queue, diff --git a/backend/package/yuxi/channels/contract/ports/driven/persistence_health_port.py b/backend/package/yuxi/channels/contract/ports/driven/persistence_health_port.py index f40cbdde..40e2f60d 100644 --- a/backend/package/yuxi/channels/contract/ports/driven/persistence_health_port.py +++ b/backend/package/yuxi/channels/contract/ports/driven/persistence_health_port.py @@ -1,10 +1,12 @@ """持久化健康检查被驱动端口。 -定义核心层对数据库可用性、连接池诊断与会话生命周期管理的依赖契约, -由 application 层调用、framework 层实现。供 ``HealthAggregator`` 与 -``DiagnosticsExporter`` 聚合健康状态与填充诊断包,并供后台扫描器在每轮 -扫描结束时释放隐式事务占用的连接(避免应用级共享 ``AsyncSession`` 长期 -持有连接导致连接池耗尽)。 +定义核心层对数据库可用性与连接池诊断的依赖契约,由 application 层调用、 +framework 层实现。供 ``HealthAggregator`` 与 ``DiagnosticsExporter`` 聚合 +健康状态与填充诊断包。 + +适配器为无状态协议转换器(不持有 session),每次方法调用通过 +``_session_scope(tx)`` 按需获取 session 并在调用结束后归还连接,无需 +额外的会话生命周期管理方法。 """ from __future__ import annotations @@ -19,12 +21,13 @@ if TYPE_CHECKING: class PersistenceHealthPort(Protocol): """持久化健康检查被驱动端口。 - 覆盖数据库可用性探测、连接池状态查询与会话生命周期管理用例。方法遵循 - 领域语义命名,入参/出参均为契约层不可变值对象。 + 覆盖数据库可用性探测与连接池状态查询。方法遵循领域语义命名,入参/出参 + 均为契约层不可变值对象。 事务边界(§10.1): - 健康检查方法无 ``tx`` 参数,按只读单次查询执行,不参与应用层 - 事务。 + 事务。适配器通过 ``_session_scope(None)`` 创建独立 session, + 查询结束后自动 close 归还连接,无需显式释放。 约束: - 复用约束(INV-I3):必须复用现有 pg_manager,不得引入新连接池。 @@ -62,21 +65,3 @@ class PersistenceHealthPort(Protocol): - DependencyError:数据库故障 """ ... - - async def releaseSession(self) -> None: - """释放当前会话占用的连接(FR-35 会话生命周期管理)。 - - 对应用级共享 ``AsyncSession`` 执行 ``commit`` 以结束隐式事务并归还 - 连接至池。SQLAlchemy 2.0 autobegin 下,只读查询会开启隐式事务并 - 持有连接直至显式提交/回滚;后台扫描器在每轮扫描结束时调用本方法, - 避免应用级共享会话长期占用连接导致连接池耗尽。 - - 语义约束: - - 无活动事务时为 no-op(``commit`` 对干净 session 安全)。 - - 不关闭会话本身(``aclose`` 负责最终关闭),仅归还连接。 - - 异常翻译为 ``DependencyError``,禁止原生异常穿透至核心层。 - - @failure - - DependencyError:提交事务时数据库故障 - """ - ... diff --git a/backend/package/yuxi/channels/contract/ports/driven/transaction_port.py b/backend/package/yuxi/channels/contract/ports/driven/transaction_port.py index 0828a0db..b9b6d3fd 100644 --- a/backend/package/yuxi/channels/contract/ports/driven/transaction_port.py +++ b/backend/package/yuxi/channels/contract/ports/driven/transaction_port.py @@ -4,18 +4,13 @@ 实现。事务边界 **必须** 由应用层(管道或用例编排器)显式开启与提交, 被驱动适配器 **不得** 自主开启跨调用的事务(§10.1)。 -事务共享机制:被驱动适配器(如 ``ChannelPersistenceAdapter`` / -``ConversationAdapter``)在构造时通过 ``create_driven_adapters(db)`` 与 -``SqlAlchemyTransactionAdapter`` 共享同一 ``AsyncSession``。应用层通过 -``TransactionPort.begin()`` 在共享 session 上开启事务,所有适配器的写 -操作自动加入同一事务。``tx`` 参数仅作为"是否自主提交"的标志位(``tx`` -非空时 ``commit=False``,由应用层统一提交)。 - -事务透传机制(C-I1):被驱动适配器(如 ``AgentRunAdapter``)在构造时 -**未** 与 ``SqlAlchemyTransactionAdapter`` 共享 session,而是通过 -``pg_manager`` 自主获取。为让其加入应用层主事务,``TransactionContext`` -暴露 ``get_session()`` 返回底层共享会话,适配器通过 ``tx.get_session()`` -复用主事务的 session,避免独立提交产生孤儿记录(C-I1)。 +事务透传机制(C-I1):被驱动适配器(如 ``ChannelPersistenceAdapter`` / +``ContentReviewRepositoryAdapter`` / ``AgentRunAdapter``)为无状态协议 +转换器,构造时仅注入 ``session_factory``(``Callable[[], AsyncSession]``), +不持有 session 实例。适配器通过 ``_session_scope(tx)`` 统一管理 session: +``tx`` 非空时通过 ``tx.get_session()`` 复用应用层主事务 session(C-I1 +透传,``commit=False``,由应用层统一提交);``tx`` 为 ``None`` 时通过 +``session_factory`` 创建独立 session 并自主提交(``commit=True``)。 """ from __future__ import annotations @@ -73,13 +68,11 @@ class TransactionContext(Protocol): 由 ``TransactionPort.begin()`` 创建,上下文管理器退出时自动提交 (无异常)或回滚(有异常)。 - 事务共享机制:被驱动适配器在构造时与事务适配器共享同一底层会话 - (如 SQLAlchemy ``AsyncSession``),``begin()`` 在共享会话上开启事务, - 所有适配器的写操作自动加入。 - - 事务透传机制(C-I1):未在构造时共享 session 的适配器(如 - ``AgentRunAdapter``)通过 ``get_session()`` 获取底层共享会话,复用 - 主事务的 session,避免独立提交产生孤儿记录。 + 事务透传机制(C-I1):无状态适配器通过 ``get_session()`` 获取底层 + 共享会话,复用主事务的 session,避免独立提交产生孤儿记录。适配器 + 在 ``_session_scope(tx)`` 中判断 ``tx`` 非空且 ``get_session()`` + 返回 session 时使用该 session(``commit=False``),否则创建独立 + session(``commit=True``)。 被驱动适配器 **不得** 自主调用 ``commit`` / ``rollback``。 @@ -89,10 +82,10 @@ class TransactionContext(Protocol): """ def get_session(self) -> AsyncSession | None: - """返回底层共享会话,供未在构造时共享 session 的适配器复用主事务。 + """返回底层共享会话,供无状态适配器复用主事务 session。 仅 SQL 实现返回真实 ``AsyncSession``,非 SQL 实现返回 ``None`` - (由调用方回退到自主获取 session 的路径)。 + (由适配器回退到 ``session_factory`` 创建独立 session 的路径)。 @pre - 事务已开启(``__aenter__`` 已调用) diff --git a/backend/package/yuxi/channels/core/model/outbox_entry.py b/backend/package/yuxi/channels/core/model/outbox_entry.py index 9504ceb7..fa757d4d 100644 --- a/backend/package/yuxi/channels/core/model/outbox_entry.py +++ b/backend/package/yuxi/channels/core/model/outbox_entry.py @@ -1,7 +1,9 @@ """发件箱条目聚合根。 实现持久化投递记录的状态机管理,封装投递成功、抑制、失败、未确认与死信 -规则。聚合根为可变 Python 类,业务方法直接修改内部状态并递增乐观锁版本。 +规则。聚合根为可变 Python 类,业务方法直接修改内部状态;乐观锁版本号 +``version`` 由持久化层(``updateOutboxEntry``)统一管理,聚合根不递增, +以支持单次持久化前的多次内存状态转换(如 ``markFailed`` + ``markDead``)。 """ from __future__ import annotations @@ -161,7 +163,6 @@ class OutboxEntry: if channel_msg_id is not None: self.channel_msg_id = channel_msg_id self.updated_at = datetime.now(UTC) - self.version += 1 # 首次调用写入 latency_ms 与 sent_at(重试成功不覆盖) if self.latency_ms is None: self.latency_ms = int((self.updated_at - self.created_at).total_seconds() * 1000) @@ -182,7 +183,6 @@ class OutboxEntry: self.status = OutboxStatus.SUPPRESSED self.last_error = reason self.updated_at = datetime.now(UTC) - self.version += 1 self.funnel_node = "suppressed" def markFailed(self, error: str) -> None: @@ -208,7 +208,6 @@ class OutboxEntry: self.last_error = error self.updated_at = datetime.now(UTC) self.last_retry_at = self.updated_at - self.version += 1 self.funnel_node = "failed" def markDead(self, reason: str) -> None: @@ -225,7 +224,6 @@ class OutboxEntry: self.next_retry_at = None self.last_error = reason self.updated_at = datetime.now(UTC) - self.version += 1 self.funnel_node = "dead" def markDeliveryUnconfirmedFailed(self, reason: str) -> None: @@ -259,7 +257,6 @@ class OutboxEntry: self.degraded_reason = reason self.updated_at = datetime.now(UTC) self.last_retry_at = self.updated_at - self.version += 1 self.funnel_node = "failed" def markPartialFailure( @@ -306,11 +303,9 @@ class OutboxEntry: self.degraded_reason = self.last_error self.updated_at = datetime.now(UTC) self.last_retry_at = self.updated_at - self.version += 1 self.funnel_node = "failed" return self.updated_at = datetime.now(UTC) - self.version += 1 def requeueForRetry(self) -> None: """将 FAILED 条目重新入队重试(FAILED → PENDING)。 @@ -327,7 +322,6 @@ class OutboxEntry: self.status = OutboxStatus.PENDING self.next_retry_at = None self.updated_at = datetime.now(UTC) - self.version += 1 self.funnel_node = "enter" def markSentUnconfirmed(self, channel_msg_id: str | None) -> None: @@ -344,7 +338,6 @@ class OutboxEntry: if channel_msg_id is not None: self.channel_msg_id = channel_msg_id self.updated_at = datetime.now(UTC) - self.version += 1 def canRetry(self) -> bool: """判断发件箱条目是否可重试。 @@ -375,7 +368,7 @@ class OutboxEntry: 调用。 状态转换:DEAD → FAILED,retry_count 重置为 0,last_error 清空, - next_retry_at 设为当前时间(立即可重投),version 递增。 + next_retry_at 设为当前时间(立即可重投)。 抛出: RuleViolationError: 当前状态非 DEAD,仅死信可复活。 @@ -387,7 +380,6 @@ class OutboxEntry: self.last_error = None self.next_retry_at = datetime.now(UTC) self.updated_at = datetime.now(UTC) - self.version += 1 self.funnel_node = "failed" def _applyStatusTransition( diff --git a/backend/package/yuxi/channels/infrastructure/channel_use_cases.py b/backend/package/yuxi/channels/infrastructure/channel_use_cases.py index d267efc2..2552c1f1 100644 --- a/backend/package/yuxi/channels/infrastructure/channel_use_cases.py +++ b/backend/package/yuxi/channels/infrastructure/channel_use_cases.py @@ -6,7 +6,7 @@ external_systems 的 ``UseCases`` + ``create_use_cases_from_db(db)`` 模式。 装配顺序: -1. ``create_driven_adapters(db)`` 创建被驱动适配器聚合 +1. ``create_driven_adapters(db, session_factory, ...)`` 创建被驱动适配器聚合 2. 从 DI 容器获取应用级单例(注册中心等) 3. 按依赖图拓扑顺序构造领域服务 4. 通过 ``Pipeline.create()`` 创建管道 @@ -25,12 +25,9 @@ from typing import Any from arq import ArqRedis from redis.asyncio import Redis -from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker from yuxi.channels.adapters import DrivenAdapters, create_driven_adapters -from yuxi.channels.adapters.content_review_repository_adapter import ( - ContentReviewRepositoryAdapter, -) from yuxi.channels.adapters.default_content_moderation_adapter import ( DefaultContentModerationAdapter, ) @@ -276,7 +273,7 @@ __all__ = [ class ChannelUseCases: """渠道用例服务聚合。 - 串联 ``create_driven_adapters(db)`` → 领域服务 → 管道 → 用例服务。 + 串联 ``create_driven_adapters(db, session_factory, ...)`` → 领域服务 → 管道 → 用例服务。 请求级创建,共享同一 ``db`` session。 对齐 external_systems 的 ``UseCases`` dataclass 模式。字段类型使用驱动 @@ -418,7 +415,7 @@ def create_channel_use_cases( """请求级 factory,创建用例服务聚合。 装配顺序(对齐 external_systems 的 ``create_use_cases_from_db`` 模式): - 1. ``create_driven_adapters(db)`` 创建被驱动适配器聚合 + 1. ``create_driven_adapters(db, session_factory, ...)`` 创建被驱动适配器聚合 2. 从 DI 容器获取应用级单例(注册中心等) 3. 创建领域服务(按依赖图拓扑顺序) 4. 通过 ``Pipeline.create()`` 创建管道 @@ -453,14 +450,19 @@ def create_channel_use_cases( # 配置变更后发布领域事件(CON-030 / ADP-029,事件由应用层发布)。 # F-02:ConfigScopeRegistry 从 DI 容器解析,提取 key_to_scope_map # 注入到请求级 RedisConfigAdapter 供 _build_key 校验作用域一致性。 + # session_factory(async_sessionmaker)从 DI 容器解析,注入到 + # ChannelPersistenceAdapter 供 _session_scope(tx) 按需创建独立 session + # (无状态适配器不持有请求级 db)。 redis_client: Redis = di_container.resolve(Redis) arq_pool: ArqRedis = di_container.resolve(ArqRedis) execution_port: AgentRunExecutionPort = di_container.resolve(AgentRunExecutionPort) event_publisher: EventPublisherPort = di_container.resolve(EventPublisherPort) queue_port: QueuePort = di_container.resolve(QueuePort) config_scope_registry: ConfigScopeRegistry = di_container.resolve(ConfigScopeRegistry) + session_factory: async_sessionmaker = di_container.resolve(async_sessionmaker) adapters: DrivenAdapters = create_driven_adapters( db, + session_factory=session_factory, outbox_config=outbox_config, redis_client=redis_client, arq_pool=arq_pool, @@ -484,10 +486,10 @@ def create_channel_use_cases( # - ContentReviewRepositoryPort:审核历史仓储端口绑定,注入 DispatchStage # (写路径)与 ContentReviewQueryService(读路径)。 default_moderation_adapter: DefaultContentModerationAdapter = di_container.resolve(DefaultContentModerationAdapter) - # 请求级构造 ContentReviewRepositoryAdapter:DI 单例持有共享 AsyncSession, - # 预览 INSERT 失败后事务进入 aborted 状态且无法回滚,污染后续所有请求。 - # 改为复用请求级 ``db`` 会话,失败时由应用层事务统一回滚。 - review_repository: ContentReviewRepositoryPort = ContentReviewRepositoryAdapter(db, logger=adapters.logger) + # ContentReviewRepositoryPort 从 DI 容器解析(无状态单例,由 + # create_host_bootstrap 注册)。适配器通过 _session_scope(tx) 按需获取 + # session:tx 非空时复用应用层主事务 session,tx 为 None 时自主创建并提交。 + review_repository: ContentReviewRepositoryPort = di_container.resolve(ContentReviewRepositoryPort) # PluginLoader 应用级单例(P1 PLG-INSTALL/UNINSTALL 文件操作),由 # create_host_bootstrap 注册到 DI 容器,注入 DispatchStage。 plugin_loader: PluginLoader = di_container.resolve(PluginLoader) @@ -1079,21 +1081,23 @@ def create_channel_session_inactive_cleanup_handler_dependencies( 调用,传入 worker 进程级 ``session_factory`` 与 ``cache_port``,返回 ``ChannelSessionInactiveCleanupHandler.__init__`` 所需的依赖字典。 - 依赖方向合规(INV-1):``session_repo_factory`` 在 infrastructure 层 - 装配具体适配器 ``ChannelPersistenceAdapter``(fat adapter,实现 + 依赖方向合规(INV-1):``session_repo`` 在 infrastructure 层装配具体 + 适配器 ``ChannelPersistenceAdapter``(fat adapter,实现 ``ChannelSessionRepositoryPort``),应用层 handler 仅依赖端口抽象, 不直接 import 适配器实现。 + 适配器无状态(注入 ``session_factory``,不持有 session 实例),通过 + ``_session_scope(tx)`` 按需获取 session,可安全在 handler 长生命周期内复用。 + Args: - session_factory: 调用返回 ``AsyncSession`` 的异步上下文管理器工厂, - 通常为 ``pg_manager.get_async_session_context``。 + session_factory: 可调用对象,调用返回 ``AsyncSession`` 实例, + 通常为 ``pg_manager.AsyncSession``。 cache_port: worker 进程级 CachePort 实例(RedisCacheAdapter), 供 handler 分布式锁使用。 Returns: 依赖字典,key 与 ``ChannelSessionInactiveCleanupHandler.__init__`` - 参数名一致:``{"session_factory": ..., "session_repo_factory": ..., - "cache_port": ..., "logger": ...}`` + 参数名一致:``{"session_repo": ..., "cache_port": ..., "logger": ...}`` """ from yuxi.channels.adapters.channel_persistence_adapter import ( ChannelPersistenceAdapter, @@ -1102,12 +1106,12 @@ def create_channel_session_inactive_cleanup_handler_dependencies( logger_port = _create_worker_logger_port() outbox_config = _create_outbox_config() - def session_repo_factory(db: AsyncSession) -> ChannelSessionRepositoryPort: - return ChannelPersistenceAdapter(db, outbox_config=outbox_config, logger=logger_port) + session_repo: ChannelSessionRepositoryPort = ChannelPersistenceAdapter( + session_factory, outbox_config=outbox_config, logger=logger_port + ) return { - "session_factory": session_factory, - "session_repo_factory": session_repo_factory, + "session_repo": session_repo, "cache_port": cache_port, "logger": logger_port, } @@ -1124,7 +1128,8 @@ def create_channel_outbox_terminal_cleanup_handler_dependencies( WARN 不影响清理主流程)。 Args: - session_factory: 调用返回 ``AsyncSession`` 的异步上下文管理器工厂。 + session_factory: 可调用对象,调用返回 ``AsyncSession`` 实例, + 通常为 ``pg_manager.AsyncSession``。 cache_port: worker 进程级 CachePort 实例(RedisCacheAdapter), 供 handler 分布式锁使用。 @@ -1139,12 +1144,12 @@ def create_channel_outbox_terminal_cleanup_handler_dependencies( logger_port = _create_worker_logger_port() outbox_config = _create_outbox_config() - def outbox_repo_factory(db: AsyncSession) -> OutboxRepositoryPort: - return ChannelPersistenceAdapter(db, outbox_config=outbox_config, logger=logger_port) + outbox_repo: OutboxRepositoryPort = ChannelPersistenceAdapter( + session_factory, outbox_config=outbox_config, logger=logger_port + ) return { - "session_factory": session_factory, - "outbox_repo_factory": outbox_repo_factory, + "outbox_repo": outbox_repo, "event_publisher": None, # worker 进程未注入事件发布端口,best-effort "cache_port": cache_port, "logger": logger_port, @@ -1158,7 +1163,8 @@ def create_channel_pairing_terminal_cleanup_handler_dependencies( """构造 ChannelPairingTerminalCleanupHandler 依赖字典(worker 进程装配用)。 Args: - session_factory: 调用返回 ``AsyncSession`` 的异步上下文管理器工厂。 + session_factory: 可调用对象,调用返回 ``AsyncSession`` 实例, + 通常为 ``pg_manager.AsyncSession``。 cache_port: worker 进程级 CachePort 实例(RedisCacheAdapter), 供 handler 分布式锁使用。 @@ -1173,12 +1179,12 @@ def create_channel_pairing_terminal_cleanup_handler_dependencies( logger_port = _create_worker_logger_port() outbox_config = _create_outbox_config() - def pairing_repo_factory(db: AsyncSession) -> PairingRepositoryPort: - return ChannelPersistenceAdapter(db, outbox_config=outbox_config, logger=logger_port) + pairing_repo: PairingRepositoryPort = ChannelPersistenceAdapter( + session_factory, outbox_config=outbox_config, logger=logger_port + ) return { - "session_factory": session_factory, - "pairing_repo_factory": pairing_repo_factory, + "pairing_repo": pairing_repo, "cache_port": cache_port, "logger": logger_port, } @@ -1191,13 +1197,19 @@ def create_channel_outbox_recovery_handler_dependencies( ) -> dict[str, Any]: """构造 ChannelOutboxRecoveryHandler 依赖字典(worker 进程装配用)。 - 依赖方向合规(INV-1):``outbox_repo_factory`` / ``audit_log_repo_factory`` - 在 infrastructure 层装配具体适配器 ``ChannelPersistenceAdapter``(fat adapter, - 分别实现 ``OutboxRepositoryPort`` / ``AuditLogRepositoryPort``),应用层 handler + 依赖方向合规(INV-1):``outbox_repo`` / ``audit_log_repo`` 在 infrastructure + 层装配具体适配器 ``ChannelPersistenceAdapter``(fat adapter,分别实现 + ``OutboxRepositoryPort`` / ``AuditLogRepositoryPort``),应用层 handler 仅依赖端口抽象,不直接 import 适配器实现。 + 适配器无状态(注入 ``session_factory``,不持有 session 实例),通过 + ``_session_scope(tx)`` 按需获取 session。恢复扫描中每条 entry 的 + ``updateOutboxEntry`` / ``saveAuditLog`` 各自独立提交,单条失败不影响其他 + 条目(与原共享 session 模式相比,避免了 session 异常状态污染后续操作)。 + Args: - session_factory: 调用返回 ``AsyncSession`` 的异步上下文管理器工厂。 + session_factory: 可调用对象,调用返回 ``AsyncSession`` 实例, + 通常为 ``pg_manager.AsyncSession``。 arq_pool: worker 进程级 ARQ 连接池,由 ``run_worker.py`` 的 ``_worker_startup`` 提前初始化后传入,用于构造 ``ARQQueueAdapter``。 cache_port: worker 进程级 CachePort 实例(RedisCacheAdapter), @@ -1214,18 +1226,18 @@ def create_channel_outbox_recovery_handler_dependencies( logger_port = _create_worker_logger_port() outbox_config = _create_outbox_config() - def outbox_repo_factory(db: AsyncSession) -> OutboxRepositoryPort: - return ChannelPersistenceAdapter(db, outbox_config=outbox_config, logger=logger_port) - - def audit_log_repo_factory(db: AsyncSession) -> AuditLogRepositoryPort: - return ChannelPersistenceAdapter(db, outbox_config=outbox_config, logger=logger_port) + outbox_repo: OutboxRepositoryPort = ChannelPersistenceAdapter( + session_factory, outbox_config=outbox_config, logger=logger_port + ) + audit_log_repo: AuditLogRepositoryPort = ChannelPersistenceAdapter( + session_factory, outbox_config=outbox_config, logger=logger_port + ) queue_port = ARQQueueAdapter(arq_pool, logger_port) return { - "session_factory": session_factory, - "outbox_repo_factory": outbox_repo_factory, - "audit_log_repo_factory": audit_log_repo_factory, + "outbox_repo": outbox_repo, + "audit_log_repo": audit_log_repo, "queue_port": queue_port, "outbox_config": outbox_config, "cache_port": cache_port, @@ -1239,13 +1251,17 @@ def create_channel_pairing_expiration_handler_dependencies( ) -> dict[str, Any]: """构造 ChannelPairingExpirationHandler 依赖字典(worker 进程装配用)。 - 依赖方向合规(INV-1):``pairing_repo_factory`` 在 infrastructure 层装配具体 + 依赖方向合规(INV-1):``pairing_repo`` 在 infrastructure 层装配具体 适配器 ``ChannelPersistenceAdapter``(fat adapter,实现 ``PairingRepositoryPort``),应用层 handler 仅依赖端口抽象,不直接 import 适配器实现。 + 适配器无状态(注入 ``session_factory``,不持有 session 实例),通过 + ``_session_scope(tx)`` 按需获取 session。 + Args: - session_factory: 调用返回 ``AsyncSession`` 的异步上下文管理器工厂。 + session_factory: 可调用对象,调用返回 ``AsyncSession`` 实例, + 通常为 ``pg_manager.AsyncSession``。 cache_port: worker 进程级 CachePort 实例(RedisCacheAdapter), 供 handler 分布式锁使用。 @@ -1259,12 +1275,12 @@ def create_channel_pairing_expiration_handler_dependencies( logger_port = _create_worker_logger_port() outbox_config = _create_outbox_config() - def pairing_repo_factory(db: AsyncSession) -> PairingRepositoryPort: - return ChannelPersistenceAdapter(db, outbox_config=outbox_config, logger=logger_port) + pairing_repo: PairingRepositoryPort = ChannelPersistenceAdapter( + session_factory, outbox_config=outbox_config, logger=logger_port + ) return { - "session_factory": session_factory, - "pairing_repo_factory": pairing_repo_factory, + "pairing_repo": pairing_repo, "cache_port": cache_port, "logger": logger_port, } @@ -1276,13 +1292,17 @@ def create_channel_audit_log_retention_handler_dependencies( ) -> dict[str, Any]: """构造 ChannelAuditLogRetentionHandler 依赖字典(worker 进程装配用)。 - 依赖方向合规(INV-1):``audit_log_repo_factory`` 在 infrastructure 层装配具体 + 依赖方向合规(INV-1):``audit_log_repo`` 在 infrastructure 层装配具体 适配器 ``ChannelPersistenceAdapter``(fat adapter,实现 ``AuditLogRepositoryPort``),应用层 handler 仅依赖端口抽象,不直接 import 适配器实现。 + 适配器无状态(注入 ``session_factory``,不持有 session 实例),通过 + ``_session_scope(tx)`` 按需获取 session。 + Args: - session_factory: 调用返回 ``AsyncSession`` 的异步上下文管理器工厂。 + session_factory: 可调用对象,调用返回 ``AsyncSession`` 实例, + 通常为 ``pg_manager.AsyncSession``。 cache_port: worker 进程级 CachePort 实例(RedisCacheAdapter), 供 handler 分布式锁使用。 @@ -1296,12 +1316,12 @@ def create_channel_audit_log_retention_handler_dependencies( logger_port = _create_worker_logger_port() outbox_config = _create_outbox_config() - def audit_log_repo_factory(db: AsyncSession) -> AuditLogRepositoryPort: - return ChannelPersistenceAdapter(db, outbox_config=outbox_config, logger=logger_port) + audit_log_repo: AuditLogRepositoryPort = ChannelPersistenceAdapter( + session_factory, outbox_config=outbox_config, logger=logger_port + ) return { - "session_factory": session_factory, - "audit_log_repo_factory": audit_log_repo_factory, + "audit_log_repo": audit_log_repo, "cache_port": cache_port, "logger": logger_port, } @@ -1313,12 +1333,16 @@ def create_channel_content_review_retention_handler_dependencies( ) -> dict[str, Any]: """构造 ChannelContentReviewRetentionHandler 依赖字典(worker 进程装配用)。 - 依赖方向合规(INV-1):``content_review_repo_factory`` 在 infrastructure + 依赖方向合规(INV-1):``content_review_repo`` 在 infrastructure 层装配具体适配器 ``ContentReviewRepositoryAdapter``,应用层 handler 仅依赖 ``ContentReviewRepositoryPort`` 端口抽象,不直接 import 适配器实现。 + 适配器无状态(注入 ``session_factory``,不持有 session 实例),通过 + ``_session_scope(tx)`` 按需获取 session。 + Args: - session_factory: 调用返回 ``AsyncSession`` 的异步上下文管理器工厂。 + session_factory: 可调用对象,调用返回 ``AsyncSession`` 实例, + 通常为 ``pg_manager.AsyncSession``。 cache_port: worker 进程级 CachePort 实例(RedisCacheAdapter), 供 handler 分布式锁使用。 @@ -1332,12 +1356,12 @@ def create_channel_content_review_retention_handler_dependencies( logger_port = _create_worker_logger_port() - def content_review_repo_factory(db: AsyncSession) -> ContentReviewRepositoryPort: - return ContentReviewRepositoryAdapter(db, logger=logger_port) + content_review_repo: ContentReviewRepositoryPort = ContentReviewRepositoryAdapter( + session_factory, logger=logger_port + ) return { - "session_factory": session_factory, - "content_review_repo_factory": content_review_repo_factory, + "content_review_repo": content_review_repo, "cache_port": cache_port, "logger": logger_port, } @@ -1349,13 +1373,17 @@ def create_channel_idempotency_cleanup_handler_dependencies( ) -> dict[str, Any]: """构造 ChannelIdempotencyCleanupHandler 依赖字典(worker 进程装配用)。 - 依赖方向合规(INV-1):``idempotency_repo_factory`` 在 infrastructure 层 + 依赖方向合规(INV-1):``idempotency_repo`` 在 infrastructure 层 装配具体适配器 ``ChannelPersistenceAdapter``(fat adapter,实现 ``IdempotencyRepositoryPort``),应用层 handler 仅依赖端口抽象,不直接 import 适配器实现。 + 适配器无状态(注入 ``session_factory``,不持有 session 实例),通过 + ``_session_scope(tx)`` 按需获取 session。 + Args: - session_factory: 调用返回 ``AsyncSession`` 的异步上下文管理器工厂。 + session_factory: 可调用对象,调用返回 ``AsyncSession`` 实例, + 通常为 ``pg_manager.AsyncSession``。 cache_port: worker 进程级 CachePort 实例(RedisCacheAdapter), 供 handler 分布式锁使用。 @@ -1369,12 +1397,12 @@ def create_channel_idempotency_cleanup_handler_dependencies( logger_port = _create_worker_logger_port() outbox_config = _create_outbox_config() - def idempotency_repo_factory(db: AsyncSession) -> IdempotencyRepositoryPort: - return ChannelPersistenceAdapter(db, outbox_config=outbox_config, logger=logger_port) + idempotency_repo: IdempotencyRepositoryPort = ChannelPersistenceAdapter( + session_factory, outbox_config=outbox_config, logger=logger_port + ) return { - "session_factory": session_factory, - "idempotency_repo_factory": idempotency_repo_factory, + "idempotency_repo": idempotency_repo, "cache_port": cache_port, "logger": logger_port, } diff --git a/backend/package/yuxi/channels/infrastructure/dependency_injection.py b/backend/package/yuxi/channels/infrastructure/dependency_injection.py index 6a503501..9c647cb4 100644 --- a/backend/package/yuxi/channels/infrastructure/dependency_injection.py +++ b/backend/package/yuxi/channels/infrastructure/dependency_injection.py @@ -7,7 +7,7 @@ 设计原则: - framework 层组件(注册中心、EventBus、熔断器等)为应用级单例,跨请求复用 - 请求级组件(被驱动适配器、管道、用例服务)由各自 factory 按请求创建,不进入容器 - (如 ``create_driven_adapters(db, outbox_config, redis_client, arq_pool, execution_port)`` + (如 ``create_driven_adapters(db, session_factory, outbox_config, redis_client, arq_pool, execution_port)`` 与 ``InboundPipeline.create()`` / ``ControlPlanePipeline.create()`` / ``OutboundPipeline.create()``) - 容器仅负责应用级单例的注册与解析,保持职责单一 diff --git a/backend/package/yuxi/channels/infrastructure/factory.py b/backend/package/yuxi/channels/infrastructure/factory.py index 00b5cdda..a390d9bd 100644 --- a/backend/package/yuxi/channels/infrastructure/factory.py +++ b/backend/package/yuxi/channels/infrastructure/factory.py @@ -14,7 +14,7 @@ from typing import Any, Protocol from arq import ArqRedis from redis.asyncio import Redis -from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker from yuxi.channels.adapters import DrivenAdapters, create_driven_adapters from yuxi.channels.adapters.agent_run_execution_adapter import ( @@ -319,6 +319,9 @@ async def _assemble_plugin_loading_core( # driven_adapters_factory 为请求级工厂,每次调用创建独立 AsyncSession # 与 DrivenAdapters 实例,供 PluginHostImpl 获取端口。 + # ChannelPersistenceAdapter 为无状态适配器,注入 ensure_schema.AsyncSession + # (session_factory)而非请求级 db 实例,通过 _session_scope(tx) 按需获取 + # session。 # F-02:传入 config_scope_registry.key_to_scope_map 供 RedisConfigAdapter # 校验作用域一致性。§13.1:传入 config_scope_registry._declared_keys 供 # _assertDeclared 校验键合法性。registry 通过 register() 原地更新内部 @@ -327,6 +330,7 @@ async def _assemble_plugin_loading_core( db = ensure_schema.AsyncSession() return create_driven_adapters( db, + session_factory=ensure_schema.AsyncSession, outbox_config=outbox_config, redis_client=redis_client, arq_pool=arq_pool, @@ -573,7 +577,7 @@ def _register_di_singletons( core: _PluginLoadingCore, *, persistence_port: PersistencePort, - persistence_db: AsyncSession, + session_factory: Callable[[], AsyncSession], queue_port: QueuePort, config_port: ConfigPort, stage_slot_injector: StageSlotInjector, @@ -600,16 +604,12 @@ def _register_di_singletons( - ``DefaultContentModerationAdapter``:未注册插件渠道时的兜底 审核适配器,供 ``create_channel_use_cases`` resolve 并注入 DispatchStage - - ``ContentReviewRepositoryAdapter``:审核历史仓储适配器,复用 - ``persistence_db`` 共享 AsyncSession(与 ChannelPersistenceAdapter - 同一会话,§10.1 共享会话约定),``tx`` 参数仅作为"是否自主提交" - 标志位。同时注册为 ``ContentReviewRepositoryPort`` 端口绑定 - (INV-3 契约显式化) - - 注:``persistence_db`` 的生命周期由 ``HostShutdown`` 通过 - ``persistence_port.aclose()`` → ``self._db.close()`` 统一管理 - (CloseablePort 契约),不注册为 ``AsyncSession`` 单例(请求级资源, - 注册为单例存在并发污染隐患)。 + - ``ContentReviewRepositoryAdapter``:审核历史仓储适配器,无状态 + (注入 ``session_factory``,不持有 session 实例),可安全注册为 + 应用级单例。``tx`` 非空时通过 ``tx.get_session()`` 复用应用层主 + 事务 session(C-I1 透传),``tx`` 为 ``None`` 时通过 + ``session_factory`` 创建独立 session 并自主提交。同时注册为 + ``ContentReviewRepositoryPort`` 端口绑定(INV-3 契约显式化) """ # 1. 注册中心与共享依赖 di_container.registerSingleton(PluginRegistry, core.plugin_registry) @@ -685,6 +685,10 @@ def _register_di_singletons( di_container.registerSingleton(IdentityResolverRegistry, identity_resolver_registry) # 3. 应用级端口与编排组件 + # session_factory(async_sessionmaker 实例)注册为 DI 单例,供 + # create_channel_use_cases 解析并注入到请求级 create_driven_adapters, + # 使 ChannelPersistenceAdapter 通过 _session_scope(tx) 按需创建独立 session。 + di_container.registerSingleton(async_sessionmaker, session_factory) di_container.registerSingleton(StageSlotInjector, stage_slot_injector) # HealthAggregator 预注册:HostBootstrap._registerCoreSingletons 也会注册, # 此处预注册保证 bootstrap 失败后 DI 容器状态完整(与 EventBus 等同理)。 @@ -706,7 +710,7 @@ def _register_di_singletons( logger=logger, ) review_repository = ContentReviewRepositoryAdapter( - db=persistence_db, + session_factory=session_factory, logger=logger, ) di_container.registerSingleton(DefaultContentModerationAdapter, default_moderation_adapter) @@ -737,8 +741,9 @@ async def create_host_bootstrap( Args: ensure_schema: 渠道 schema 初始化端口(``PostgresManager`` 满足此 Protocol,提供 ``ensure_channel_schema`` 方法)。同时作为数据库 - 会话工厂来源,通过 ``ensure_schema.AsyncSession()`` 创建应用级 - 与请求级 ``AsyncSession``。 + 会话工厂来源,``ensure_schema.AsyncSession``(可调用对象)注入到 + ``ChannelPersistenceAdapter`` / ``ContentReviewRepositoryAdapter``, + 适配器按需调用创建独立 ``AsyncSession``,不在应用级持有共享会话。 logger: 日志被驱动端口,供所有 framework 层组件注入。 plugin_dir: 插件目录路径,供 ``PluginLifecycleManager.discover`` 扫描。 di_container: 依赖注入容器,由调用方创建并传入。HostBootstrap 的 @@ -763,11 +768,11 @@ async def create_host_bootstrap( core = await _assemble_plugin_loading_core(logger, ensure_schema, plugin_dir=plugin_dir) # 2. 构造应用级 persistence_port 与 queue_port - # persistence_port 需要 AsyncSession,创建应用级会话供 TransportManager / - # HealthAggregator / DiagnosticsExporter 共用;queue_port 无需会话。 - persistence_db = ensure_schema.AsyncSession() + # persistence_port 注入 session_factory(Callable[[], AsyncSession]), + # 适配器无状态,每次方法调用通过 _session_scope(tx) 按需获取 session; + # queue_port 无需会话。 persistence_port: PersistencePort = ChannelPersistenceAdapter( - persistence_db, outbox_config=core.outbox_config, logger=logger + ensure_schema.AsyncSession, outbox_config=core.outbox_config, logger=logger ) queue_port: QueuePort = ARQQueueAdapter(arq_pool=core.arq_pool, logger=logger) @@ -867,7 +872,7 @@ async def create_host_bootstrap( di_container=di_container, core=core, persistence_port=persistence_port, - persistence_db=persistence_db, + session_factory=ensure_schema.AsyncSession, queue_port=queue_port, config_port=config_port, stage_slot_injector=stage_slot_injector, @@ -881,6 +886,8 @@ async def create_host_bootstrap( # 供 ``_loadConfig`` 加载渠道配置、``_initDrivenAdapters`` 对应用级 # 被驱动适配器执行连通性检查;driving_adapter_unregistrar 用于启动 # 失败回滚时注销驱动适配器) + # persistence_port 保留在元组中供启动期 ping 连通性检查(PingablePort), + # 但不再实现 CloseablePort(无状态适配器),HostShutdown 关停时跳过 close。 app_driven_adapters = ( core.cache_port, persistence_port, @@ -923,8 +930,10 @@ def create_host_shutdown( INF-017:同时从 DI 容器解析 4 个应用级被驱动适配器 (``cache_port`` / ``persistence_port`` / ``queue_port`` / ``config_port``) - 传入 ``app_driven_adapters`` 参数,供 ``HostShutdown`` 在关停步骤 6 - 显式调用 ``close()`` 释放资源。 + 传入 ``app_driven_adapters`` 参数,供 ``HostBootstrap`` 启动期执行 + ping 连通性检查、``HostShutdown`` 关停期对实现 ``CloseablePort`` 的 + 适配器调用 ``close()`` 释放资源。``persistence_port`` 为无状态适配器 + (不持有 session),关停时通过 ``isinstance(CloseablePort)`` 跳过。 INF-006:依赖来源由模块级全局状态改为 DI 容器, 消除模块级可变状态(INV-5)。 @@ -947,6 +956,8 @@ def create_host_shutdown( 执行关停编排。 """ # 解析应用级被驱动适配器(HostBootstrap 构造时注册到 DI 容器) + # persistence_port 保留在元组中(启动期 ping 检查需要),关停时由 + # HostShutdown 通过 isinstance(CloseablePort) 跳过(无状态适配器无需 close) app_driven_adapters = ( di_container.resolve(CachePort), di_container.resolve(PersistencePort), diff --git a/backend/package/yuxi/channels/infrastructure/host_bootstrap.py b/backend/package/yuxi/channels/infrastructure/host_bootstrap.py index 38e4ac29..d5b1cf92 100644 --- a/backend/package/yuxi/channels/infrastructure/host_bootstrap.py +++ b/backend/package/yuxi/channels/infrastructure/host_bootstrap.py @@ -688,10 +688,12 @@ class HostBootstrap: async def _initDrivenAdapters(self) -> None: """加载并初始化被驱动适配器(INF-010:应用级适配器连通性检查)。 - 被驱动适配器为请求级组件,由 ``create_driven_adapters(db)`` 按请求创建, - 共享同一 ``db`` session。本步骤对 ``HostBootstrap`` 构造时传入的应用级 - 被驱动适配器(``cache_port`` / ``persistence_port`` / ``queue_port`` / - ``config_port`` 等跨请求共享实例)执行连通性检查,确保下游依赖可达: + 被驱动适配器为请求级组件,由 ``create_driven_adapters(db, session_factory, ...)`` + 按请求创建,其中 ``conversation`` / ``transaction`` 共享同一 ``db`` session, + ``persistence`` 为无状态适配器(注入 ``session_factory``)。本步骤对 + ``HostBootstrap`` 构造时传入的应用级被驱动适配器(``cache_port`` / + ``persistence_port`` / ``queue_port`` / ``config_port`` 等跨请求共享实例) + 执行连通性检查,确保下游依赖可达: 任一失败必须抛 ``DependencyError`` 终止启动(INV-I5),避免启动后请求期 才发现依赖故障。 diff --git a/backend/package/yuxi/channels/infrastructure/host_shutdown.py b/backend/package/yuxi/channels/infrastructure/host_shutdown.py index 495a3c39..3ff21740 100644 --- a/backend/package/yuxi/channels/infrastructure/host_shutdown.py +++ b/backend/package/yuxi/channels/infrastructure/host_shutdown.py @@ -442,18 +442,21 @@ class HostShutdown: 应用级被驱动适配器(``cache_port`` / ``persistence_port`` / ``queue_port`` / ``config_port``)在 ``HostBootstrap`` 构造时创建, - 跨请求共享,需在关停时显式关闭以释放 Redis 连接、DB 会话等资源。 - 插件被驱动适配器由 ``PluginRegistry`` 管理,通过 - ``listPluginAdapters`` 遍历关闭。 + 跨请求共享,需在关停时显式关闭以释放 Redis 连接等资源。 + ``persistence_port`` 为无状态适配器(不持有 session),不实现 + ``CloseablePort``,遍历时跳过。插件被驱动适配器由 ``PluginRegistry`` + 管理,通过 ``listPluginAdapters`` 遍历关闭。 单个适配器关闭异常记录告警并继续,确保后续适配器仍能关闭(避免 资源泄漏,INV-9 回退路径完整性)。 """ # 1. 关闭应用级被驱动适配器(cache / persistence / queue / config) + # persistence_port 为无状态适配器(不持有 session),不实现 + # CloseablePort,isinstance 检查为 False 时跳过(DEBUG 级别记录)。 for adapter in self._app_driven_adapters: try: if not isinstance(adapter, CloseablePort): - await self._logger.info( + await self._logger.debug( "应用级被驱动适配器未实现 CloseablePort,跳过", adapter_type=type(adapter).__name__, ) diff --git a/backend/package/yuxi/channels/infrastructure/scheduler.py b/backend/package/yuxi/channels/infrastructure/scheduler.py index 87a73f4f..b6d77e4e 100644 --- a/backend/package/yuxi/channels/infrastructure/scheduler.py +++ b/backend/package/yuxi/channels/infrastructure/scheduler.py @@ -92,7 +92,11 @@ async def register_scheduler_handlers(registry: HandlerRegistry) -> None: create_worker_cache_port, ) - session_factory = pg_manager.get_async_session_context + # ``pg_manager.AsyncSession`` 为 ``async_sessionmaker`` 实例(callable, + # 调用返回 ``AsyncSession``),符合 ``Callable[[], AsyncSession]`` 协议。 + # 由 ``run_worker._worker_startup`` 在调用本函数前通过 ``pg_manager.initialize()`` + # 初始化完成。 + session_factory = pg_manager.AsyncSession arq_pool = await get_arq_pool() cache_port = await create_worker_cache_port() diff --git a/backend/package/yuxi/channels/plugins/wechat_woc/adapters/inbound_adapter.py b/backend/package/yuxi/channels/plugins/wechat_woc/adapters/inbound_adapter.py index 9259a8c9..c9160e25 100644 --- a/backend/package/yuxi/channels/plugins/wechat_woc/adapters/inbound_adapter.py +++ b/backend/package/yuxi/channels/plugins/wechat_woc/adapters/inbound_adapter.py @@ -76,8 +76,8 @@ _GROUP_TALKER_SUFFIX = "@chatroom" # 拒绝含空格、斜杠、中文等异常字符的 talker/sender,防止 bridge 数据污染下游。 _WXID_RE = re.compile(r"^[A-Za-z0-9_@-]+$") -# P2-12: session_type 合法枚举值(bridge 返回的会话类型) -_VALID_SESSION_TYPES = frozenset({"single", "group"}) +# P2-12: session_type 合法枚举值(bridge 返回的会话类型,与 session_adapter 的 chat_type 保持一致) +_VALID_SESSION_TYPES = frozenset({"p2p", "group"}) class WeChatWocInboundAdapter: diff --git a/backend/package/yuxi/channels/plugins/wechat_woc/woc_bridge_client.py b/backend/package/yuxi/channels/plugins/wechat_woc/woc_bridge_client.py index 6c7870e5..6ecf49c0 100644 --- a/backend/package/yuxi/channels/plugins/wechat_woc/woc_bridge_client.py +++ b/backend/package/yuxi/channels/plugins/wechat_woc/woc_bridge_client.py @@ -643,9 +643,12 @@ class WocBridgeClient: if last_exc is not None: raise last_exc except httpx.HTTPError as exc: - await self._logger.exception( + # 仅记录关键诊断字段,不打印原始 httpx 堆栈(易误判为异常穿透)。 + # 翻译后的契约层异常(OperationTimeoutError/DependencyError 等)由 + # translate_call_error 抛出,调用方 catch 时自行记录完整堆栈。 + await self._logger.error( "woc bridge HTTP request failed", - exc_info=exc, + error_type=type(exc).__name__, method=method, url=_redact_url(url), trace_id=trace_id,