"""ConversationAdapter:实现 ConversationPort,复用现有 Conversation/Message 表。 - 必须复用现有 Conversation/Message 表 - 不得新建渠道专属消息/会话表 - 咨询锁必须复用 PostgreSQL pg_advisory_xact_lock - 会话合并必须幂等可回滚,失败时保留原数据并记录审计日志 - 事务边界:写操作接受可选 ``tx`` 参数(TransactionContext), ``tx`` 非空时加入应用层事务,**不得** 自主提交;``tx`` 为 ``None`` 时 按单方法提交(向后兼容)。 依赖边界:只依赖 yuxi.channels.contract(端口 + DTO + 错误)、 yuxi.repositories.channels(ChannelConversationRepository / ChannelMessageRepository / ChannelSessionRepository)、yuxi.storage.postgres.manager(pg_manager)、 sqlalchemy(pg_advisory_xact_lock)。 """ from __future__ import annotations import uuid import zlib from datetime import datetime from typing import TYPE_CHECKING, Any from sqlalchemy import String, func, select, text, true from sqlalchemy import update as sa_update from sqlalchemy.exc import IntegrityError, SQLAlchemyError from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload from yuxi.channels.contract.dtos.channel import ChannelType, Message, MessageSearchItem from yuxi.channels.contract.dtos.conversation import ( AppendOperationHistoryCmd, AssociateConversationCmd, MergeConversationCmd, MergeResult, ResolveConversationCmd, SaveMessageCmd, UpdateMessageChannelStatusCmd, ) from yuxi.channels.contract.dtos.dashboard import MessageStats from yuxi.channels.contract.dtos.session import OwnerTransferCmd, SessionOwner from yuxi.channels.contract.errors import ( ConflictError, DependencyError, NotFoundError, ValidationError, ) from yuxi.channels.contract.errors.base import Error from yuxi.channels.contract.ports.driven.conversation_port import ConversationPort from yuxi.channels.contract.ports.driven.logger_port import LoggerPort from yuxi.repositories.channels import ( ChannelConversationRepository, ChannelMessageRepository, ChannelSessionRepository, ) from yuxi.storage.postgres.models_business import ( Conversation as ConversationORM, ) from yuxi.storage.postgres.models_business import ( ConversationStats as ConversationStatsORM, ) from yuxi.storage.postgres.models_business import ( Message as MessageORM, ) from yuxi.storage.postgres.models_channels import ( ChannelAccount as ChannelAccountORM, ) from yuxi.storage.postgres.models_channels import ( ChannelSession as ChannelSessionORM, ) from yuxi.utils.datetime_utils import UTC, utc_now_naive if TYPE_CHECKING: from yuxi.channels.contract.ports.driven.transaction_port import ( TransactionContext, ) __all__ = ["ConversationAdapter"] def _to_naive_utc(value: datetime | None) -> datetime | None: """将 aware datetime 归一化为 naive UTC,供与 naive ORM 列比较。 ORM ``DateTime`` 列以 UTC naive 存储(项目约定),上层传入的 aware datetime 需在适配器边界归一化为 UTC naive,避免 ``can't compare offset-naive and offset-aware datetimes`` 或 Postgres 类型不匹配错误。 naive 输入视为 UTC naive 直接返回(与 DB 存储约定一致)。 """ if value is None: return None if value.tzinfo is not None: return value.astimezone(UTC).replace(tzinfo=None) return value class ConversationAdapter(ConversationPort): """会话被驱动适配器实现。 复用现有 Conversation/Message 表与 ChannelSessionRepository / ChannelConversationRepository / ChannelMessageRepository,不新建渠道专属表。 所有方法均为 async,入参/出参均为契约层不可变值对象。 事务边界: - 写操作接受可选 ``tx`` 参数(``TransactionContext``)。 - ``tx`` 非空时加入应用层事务,向 Repo 传 ``commit=False``,由应用层 统一提交。 - ``tx`` 为 ``None`` 时按单方法提交(向后兼容,``commit=True``)。 """ def __init__(self, db: AsyncSession, logger: LoggerPort) -> None: """初始化适配器,注入共享数据库会话与三个仓储实例。 Args: db: SQLAlchemy 异步会话,所有仓储共享以保证事务一致性。 logger: 日志被驱动端口,用于记录 DB 异常翻译路径上的故障 (INV-10 可观测性)。强制注入,禁止使用全局 logger。 """ self._db = db self._logger = logger self._conversation_repo = ChannelConversationRepository(db) self._message_repo = ChannelMessageRepository(db) self._session_repo = ChannelSessionRepository(db) def _translate_db_error(self, exc: Exception, resource: str) -> Error: """将数据库异常翻译为契约层错误。 IntegrityError 映射为 ConflictError(并发冲突),其余 SQLAlchemy 异常 映射为 DependencyError(依赖故障),禁止原生异常穿透至核心层。 Args: exc: 原始数据库异常。 resource: 资源标识,用于错误信息。 Returns: 契约层 Error 实例。 """ if isinstance(exc, IntegrityError): 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 def _primary_session_subq(self) -> Any: """构造获取会话主渠道会话的 LATERAL 子查询。 每个 Conversation 可能关联多个 ChannelSession(跨渠道关联 FR-06)。 在消息列表/搜索/详情中,只需展示一条代表会话上下文的渠道会话。 本子查询按 ``created_at`` 倒序取最新一条未软删除的渠道会话,并携带 关联 ChannelAccount 的业务 ``account_id`` 字段。 """ return ( select( ChannelSessionORM.session_id, ChannelSessionORM.peer_id, ChannelAccountORM.account_id.label("channel_account_id"), ) .join(ChannelAccountORM, ChannelAccountORM.id == ChannelSessionORM.account_id) .where( ChannelSessionORM.conversation_id == ConversationORM.id, ChannelSessionORM.is_deleted == 0, ) .order_by(ChannelSessionORM.created_at.desc()) .limit(1) .subquery() .lateral() ) def _apply_message_filters( self, stmt: Any, *, channel_type: ChannelType | None = None, role: str | list[str] | tuple[str, ...] | None = None, start_naive: datetime | None = None, end_naive: datetime | None = None, channel_status: str | list[str] | tuple[str, ...] | None = None, account_id: str | None = None, peer_id: str | None = None, message_id: str | None = None, channel_msg_id: str | None = None, channel_session_id: str | None = None, ) -> Any: """向已 join ConversationORM 的查询语句追加通用过滤条件。 ``role`` / ``channel_status`` 支持单值字符串或序列(list/tuple)。 涉及渠道会话/账户的过滤使用 ``EXISTS`` 子查询,避免 1:N join 导致 消息行重复。所有字符串过滤均使用 ``ilike`` 不区分大小写。 """ if channel_type is not None: stmt = stmt.where(ConversationORM.channel_type == channel_type) if isinstance(role, (list, tuple)): stmt = stmt.where(MessageORM.role.in_(role)) elif role is not None: stmt = stmt.where(MessageORM.role == role) if start_naive is not None: stmt = stmt.where(MessageORM.created_at >= start_naive) if end_naive is not None: stmt = stmt.where(MessageORM.created_at <= end_naive) if isinstance(channel_status, (list, tuple)): stmt = stmt.where(MessageORM.channel_status.in_(channel_status)) elif channel_status is not None: stmt = stmt.where(MessageORM.channel_status == channel_status) if account_id is not None: account_pattern = f"%{account_id}%" account_exists = ( select(1) .where( ChannelSessionORM.conversation_id == ConversationORM.id, ChannelSessionORM.is_deleted == 0, ChannelSessionORM.account_id == ChannelAccountORM.id, ChannelAccountORM.account_id.ilike(account_pattern), ) .exists() ) stmt = stmt.where(account_exists) if peer_id is not None: peer_pattern = f"%{peer_id}%" peer_exists = ( select(1) .where( ChannelSessionORM.conversation_id == ConversationORM.id, ChannelSessionORM.is_deleted == 0, ChannelSessionORM.peer_id.ilike(peer_pattern), ) .exists() ) stmt = stmt.where(peer_exists) if message_id is not None: stmt = stmt.where(MessageORM.id.cast(String).ilike(f"%{message_id}%")) if channel_msg_id is not None: stmt = stmt.where(MessageORM.channel_msg_id.ilike(f"%{channel_msg_id}%")) if channel_session_id is not None: session_pattern = f"%{channel_session_id}%" session_exists = ( select(1) .where( ChannelSessionORM.conversation_id == ConversationORM.id, ChannelSessionORM.is_deleted == 0, ChannelSessionORM.session_id.ilike(session_pattern), ) .exists() ) stmt = stmt.where(session_exists) return stmt def _attach_context( self, message: Message, conversation: Any | None, session: Any | None, ) -> Message: """将查询得到的会话上下文附加到 ``Message`` 值对象。 ``Message`` 为 ``frozen=True`` 的 dataclass,额外字段通过 ``object.__setattr__`` 注入,供 ``_messageToDict`` 序列化时读取。 Args: message: 契约层 Message 值对象。 conversation: Conversation ORM 实例(可选)。 session: 主渠道会话 lateral 子查询行(可选),含 ``session_id`` / ``peer_id`` / ``channel_account_id``。 Returns: 附加了上下文字段的 Message 值对象(同一实例)。 """ if conversation is not None: object.__setattr__(message, "conversation_title", getattr(conversation, "title", None)) if session is not None: object.__setattr__(message, "channel_session_id", getattr(session, "session_id", None)) object.__setattr__(message, "peer_id", getattr(session, "peer_id", None)) object.__setattr__( message, "channel_account_id", getattr(session, "channel_account_id", None), ) return message def _orm_to_message(self, orm) -> Message: """将 Message ORM 实例转换为契约层 Message dataclass。 仅做字段映射,不涉及敏感字段加解密(Message 无敏感字段)。 ``channel_type`` 字段填充来源:通过 ``Message`` ORM 已声明的 ``conversation = relationship("Conversation", back_populates="messages")`` relationship 直接访问 ``orm.conversation.channel_type``,无需额外 join 查询(调用方需通过 ``selectinload`` / ``joinedload`` 预加载 relationship,或在查询时显式 join ``ConversationORM``)。若 ``orm.conversation`` 为 ``None`` 或 ``orm.conversation.channel_type`` 为 ``None``,则填充 ``channel_type=None``。 Args: orm: Message ORM 实例。 Returns: 契约层 Message 不可变值对象。 """ return Message( message_id=str(orm.id), conversation_id=str(orm.conversation_id), role=orm.role, content=orm.content, channel_status=orm.channel_status, channel_msg_id=orm.channel_msg_id, ref_channel_msg_id=orm.ref_channel_msg_id, channel_status_history=tuple(orm.channel_status_history) if orm.channel_status_history is not None else None, operations_history=tuple(orm.operations_history) if orm.operations_history is not None else None, channel_read_at=orm.channel_read_at, channel_recalled_at=orm.channel_recalled_at, channel_edited_at=orm.channel_edited_at, created_at=orm.created_at, channel_type=ChannelType(orm.conversation.channel_type) if orm.conversation and orm.conversation.channel_type else None, ) async def saveMessage( self, cmd: SaveMessageCmd, tx: TransactionContext | None = None, ) -> Message: """保存消息(持久化投递)。 双路径语义: - **入站回复**:消息本体由应用服务层(``ConversationRepository.add_message`` 等)预先创建,本方法定位会话最新消息并初始化其渠道扩展字段。 - **管理员直发**:会话无消息时(新会话或无历史消息),本方法创建 新 Message 记录并初始化渠道扩展字段。此路径支撑管理员通过数据面 ``sendAdminMessage`` 直接发送消息的场景(无 Agent/LLM 预创建消息)。 Args: cmd: 保存消息命令,携带会话 ID、角色、内容与渠道侧初始状态字段。 tx: 事务上下文,非空时加入应用层事务,**不得** 自主提交。 Returns: 更新或创建后的 Message 值对象。 Raises: NotFoundError: 会话 ID 非法时抛出。 ConflictError: 并发冲突时抛出。 DependencyError: 数据库故障时抛出。 """ commit = self._should_commit(tx) try: conversation_id = int(cmd.conversation_id) except ValueError as exc: raise NotFoundError("conversation", cmd.conversation_id) from exc try: latest = await self._findLatestMessage(conversation_id) if latest is None: # 管理员直发路径:会话无消息时创建新 Message 记录 latest = await self._createMessage(cmd, conversation_id, commit=commit) else: # 入站回复路径:初始化预创建消息的渠道扩展字段 message_id = latest.id latest = await self._message_repo.init_channel_fields( message_id, channel_status=cmd.channel_status, channel_msg_id=cmd.channel_msg_id, ref_channel_msg_id=cmd.ref_channel_msg_id, channel_status_history=cmd.channel_status_history, commit=commit, ) if latest is None: raise NotFoundError("message", str(message_id)) # init_channel_fields 的 commit/flush 会使 conversation 关系过期 # (expire_on_commit=True),需重新加载以避免 _orm_to_message # 访问 orm.conversation.channel_type 触发 lazy-load 抛 # MissingGreenlet(SQLAlchemyError 子类)。 await self._db.refresh(latest, ["conversation"]) return self._orm_to_message(latest) except IntegrityError as exc: raise self._translate_db_error(exc, "message") from exc except SQLAlchemyError as exc: await self._logger.error( "conversation_save_message_failed", resource="message", error=str(exc), ) raise self._translate_db_error(exc, "message") from exc except Error: raise except Exception as exc: await self._logger.error( "conversation_save_message_unexpected_error", resource="message", error=str(exc), ) raise DependencyError("message", Error(str(exc))) from exc async def _findLatestMessage(self, conversation_id: int) -> MessageORM | None: """查询会话最新消息(按 ``created_at`` 降序取首条)。 预加载 ``conversation`` relationship 以避免 async SQLAlchemy 中 访问 ``orm.conversation.channel_type`` 触发 lazy-load 抛 ``MissingGreenlet``(``SQLAlchemyError`` 子类)。 Args: conversation_id: 内部会话 ID(主键)。 Returns: 最新 Message ORM 实例;无消息时返回 None。 """ stmt = ( select(MessageORM) .options(selectinload(MessageORM.conversation)) .where(MessageORM.conversation_id == conversation_id) .order_by(MessageORM.created_at.desc()) .limit(1) ) result = await self._db.execute(stmt) return result.scalar_one_or_none() async def _createMessage( self, cmd: SaveMessageCmd, conversation_id: int, *, commit: bool, ) -> MessageORM: """创建新 Message 记录并初始化渠道扩展字段。 用于管理员直发场景(无 Agent/LLM 预创建消息)。创建消息时一次性 写入渠道侧初始状态字段(``channel_status`` 等),避免二次 UPDATE。 创建后通过 ``refresh`` 加载 ``conversation`` relationship,避免 async SQLAlchemy 中 ``_orm_to_message`` 访问 ``orm.conversation`` 触发 lazy-load 抛 ``MissingGreenlet``。 Args: cmd: 保存消息命令,提供 ``role`` / ``content`` / 渠道字段。 conversation_id: 内部会话 ID(主键)。 commit: True 时提交事务,False 时仅 flush。 Returns: 创建后的 Message ORM 实例(含主键与 ``conversation`` 已加载)。 """ message = MessageORM( conversation_id=conversation_id, role=cmd.role, content=cmd.content, message_type="text", extra_metadata={}, delivery_status="complete", channel_status=cmd.channel_status, channel_msg_id=cmd.channel_msg_id, ref_channel_msg_id=cmd.ref_channel_msg_id, channel_status_history=list(cmd.channel_status_history) if cmd.channel_status_history is not None else None, ) self._db.add(message) if commit: await self._db.commit() else: await self._db.flush() # 加载 conversation relationship 供 _orm_to_message 访问 await self._db.refresh(message, ["conversation"]) return message async def updateMessageChannelStatus( self, cmd: UpdateMessageChannelStatusCmd, tx: TransactionContext | None = None, ) -> Message: """渠道侧消息状态回写。 更新 channel_status 并追加 channel_status_history,按状态填充对应 时间戳(read_at / recalled_at / edited_at)。可选补全 channel_msg_id 字段(用于"持久化记录创建消息"场景)。 Args: cmd: 状态回写命令,携带消息 ID、新状态与事件条目。 tx: 事务上下文,非空时加入应用层事务,**不得** 自主提交。 Returns: 更新后的 Message 值对象。 Raises: NotFoundError: 消息不存在或消息 ID 非法时抛出。 ConflictError: 并发冲突时抛出。 DependencyError: 数据库故障时抛出。 """ commit = self._should_commit(tx) try: message_id = int(cmd.message_id) except ValueError as exc: raise NotFoundError("message", cmd.message_id) from exc try: updated = await self._message_repo.update_channel_status( message_id, channel_status=cmd.channel_status, event=cmd.event, read_at=cmd.read_at, recalled_at=cmd.recalled_at, edited_at=cmd.edited_at, channel_msg_id=cmd.channel_msg_id, commit=commit, ) if updated is None: raise NotFoundError("message", cmd.message_id) return self._orm_to_message(updated) except IntegrityError as exc: raise self._translate_db_error(exc, "message") from exc except SQLAlchemyError as exc: await self._logger.error( "conversation_update_message_channel_status_failed", resource="message", error=str(exc), ) raise self._translate_db_error(exc, "message") from exc except Error: raise except Exception as exc: await self._logger.error( "conversation_update_message_channel_status_unexpected_error", resource="message", error=str(exc), ) raise DependencyError("message", Error(str(exc))) from exc async def getMessages( self, conversation_id: str, limit: int = 50, offset: int = 0, start_time: datetime | None = None, end_time: datetime | None = None, channel_type: ChannelType | None = None, ) -> tuple[Message, ...]: """查询会话消息列表。 返回指定会话的消息(含渠道侧状态字段),按时间升序,支持分页 (``offset``)与时间范围过滤(``start_time`` / ``end_time``)。 ``channel_type`` 非空时校验会话归属渠道,不一致时返回空元组 (语义:该渠道下无此会话的消息,避免跨渠道越权查询)。 Args: conversation_id: 会话 ID。 limit: 返回数量上限,默认 50。 offset: 分页偏移量,默认 0。 start_time: 起始时间(可选,含边界)。 end_time: 截止时间(可选,含边界)。 channel_type: 渠道类型(可选,非空时按渠道过滤)。 Returns: Message 元组,按时间升序排列,跳过前 offset 条。 Raises: ValidationError: 会话 ID 非法时抛出。 ConflictError: 并发冲突时抛出。 DependencyError: 数据库故障时抛出。 """ try: cid = int(conversation_id) except ValueError as exc: raise ValidationError( "conversation_id", "conversation_id format invalid", id=conversation_id, ) from exc try: stmt = select(MessageORM).where(MessageORM.conversation_id == cid) if channel_type is not None: stmt = stmt.join( ConversationORM, ConversationORM.id == MessageORM.conversation_id, ).where(ConversationORM.channel_type == channel_type) if start_time is not None: stmt = stmt.where(MessageORM.created_at >= start_time) if end_time is not None: stmt = stmt.where(MessageORM.created_at <= end_time) stmt = stmt.order_by(MessageORM.created_at.asc()).limit(limit).offset(offset) result = await self._db.execute(stmt) orms = result.scalars().all() return tuple(self._orm_to_message(orm) for orm in orms) except IntegrityError as exc: raise self._translate_db_error(exc, "message") from exc except SQLAlchemyError as exc: await self._logger.error( "conversation_get_messages_failed", resource="message", error=str(exc), ) raise self._translate_db_error(exc, "message") from exc except Error: raise except Exception as exc: await self._logger.error( "conversation_get_messages_unexpected_error", resource="message", error=str(exc), ) raise DependencyError("message", Error(str(exc))) from exc async def getMessageByChannelMsgId( self, channel_msg_id: str, channel_type: ChannelType | None = None, ) -> Message | None: """按渠道消息 ID 查询消息(状态回写定位)。 利用 ``idx_messages_channel_msg_id`` 索引加速查询,供入站状态路由 阶段通过状态事件的 ``ref_channel_msg_id`` 定位对应消息。 ``channel_type`` 非空时按渠道过滤,避免跨渠道 ``channel_msg_id`` 碰撞导致误定位(``channel_msg_id`` 列无唯一约束)。 Args: channel_msg_id: 渠道侧消息 ID。 channel_type: 渠道类型(可选,非空时按渠道过滤)。 Returns: 匹配的 Message 值对象;不存在时返回 None。 Raises: ConflictError: 并发冲突时抛出。 DependencyError: 数据库故障时抛出。 """ if not channel_msg_id: return None try: stmt = select(MessageORM).where(MessageORM.channel_msg_id == channel_msg_id) if channel_type is not None: stmt = stmt.join( ConversationORM, ConversationORM.id == MessageORM.conversation_id, ).where(ConversationORM.channel_type == channel_type) result = await self._db.execute(stmt) orm = result.scalar_one_or_none() if orm is None: return None return self._orm_to_message(orm) except IntegrityError as exc: raise self._translate_db_error(exc, "message") from exc except SQLAlchemyError as exc: await self._logger.error( "conversation_get_message_by_channel_msg_id_failed", resource="message", error=str(exc), ) raise self._translate_db_error(exc, "message") from exc except Error: raise except Exception as exc: await self._logger.error( "conversation_get_message_by_channel_msg_id_unexpected_error", resource="message", error=str(exc), ) raise DependencyError("message", Error(str(exc))) from exc async def countMessages( self, conversation_id: str, start_time: datetime | None = None, end_time: datetime | None = None, channel_type: ChannelType | None = None, ) -> int: """统计会话消息总数(分页用例)。 返回满足过滤条件的消息总数,供分页响应计算总页数。过滤条件与 ``getMessages`` 保持一致(``channel_type`` 非空时校验会话归属渠道)。 Args: conversation_id: 会话 ID。 start_time: 起始时间(可选,含边界)。 end_time: 截止时间(可选,含边界)。 channel_type: 渠道类型(可选,非空时按渠道过滤)。 Returns: 满足条件的消息总数。 Raises: ValidationError: 会话 ID 非法时抛出。 ConflictError: 并发冲突时抛出。 DependencyError: 数据库故障时抛出。 """ try: cid = int(conversation_id) except ValueError as exc: raise ValidationError( "conversation_id", "conversation_id format invalid", id=conversation_id, ) from exc try: stmt = select(func.count()).select_from(MessageORM).where(MessageORM.conversation_id == cid) if channel_type is not None: stmt = stmt.join( ConversationORM, ConversationORM.id == MessageORM.conversation_id, ).where(ConversationORM.channel_type == channel_type) if start_time is not None: stmt = stmt.where(MessageORM.created_at >= start_time) if end_time is not None: stmt = stmt.where(MessageORM.created_at <= end_time) result = await self._db.execute(stmt) return result.scalar_one() except IntegrityError as exc: raise self._translate_db_error(exc, "message") from exc except SQLAlchemyError as exc: await self._logger.error( "conversation_count_messages_failed", resource="message", error=str(exc), ) raise self._translate_db_error(exc, "message") from exc except Error: raise except Exception as exc: await self._logger.error( "conversation_count_messages_unexpected_error", resource="message", error=str(exc), ) raise DependencyError("message", Error(str(exc))) from exc async def resolveConversation( self, cmd: ResolveConversationCmd, tx: TransactionContext | None = None, ) -> str: """解析或创建会话(跨渠道会话关联)。 解析顺序: 1. 按对端 ID、渠道类型与账户 ID 查询已关联的渠道会话,命中则 返回其 conversation_id(同渠道复用)。 2. 当 ``cmd.unified_identity_id`` 非空时(关联策略),使用 ``pg_advisory_xact_lock`` 串行化同一身份的关联操作,再按 unified_identity_id 查询已有跨渠道会话,命中则复用(新渠道会话指向已有会话,历史消息自动可见)。 3. 未命中且 ``create_if_not_found=True`` 时创建新 Conversation (含 ConversationStats),写入 unified_identity_id 与 user_id。 Args: cmd: 解析会话命令,携带对端 ID、渠道类型、账户 ID、统一身份 ID 与真实用户 ID。 tx: 事务上下文,非空时加入应用层事务,**不得** 自主提交。 Returns: 会话 ID 字符串。 Raises: NotFoundError: ``create_if_not_found=False`` 时未找到已绑定的会话, 或渠道账户不存在时抛出。 ConflictError: 并发冲突时抛出。 DependencyError: 数据库故障时抛出。 """ try: account_orm = await self._db.execute( select(ChannelAccountORM.id).where( ChannelAccountORM.channel_type == cmd.channel_type, ChannelAccountORM.account_id == cmd.account_id, ChannelAccountORM.is_deleted == 0, ) ) account_id_int = account_orm.scalar_one_or_none() if account_id_int is None: raise NotFoundError("channel_account", cmd.account_id) stmt = ( select(ChannelSessionORM) .where(ChannelSessionORM.peer_id == cmd.peer_id) .where(ChannelSessionORM.channel_type == cmd.channel_type) .where(ChannelSessionORM.account_id == account_id_int) .where(ChannelSessionORM.is_deleted == 0) .order_by(ChannelSessionORM.created_at.desc()) .limit(1) ) result = await self._db.execute(stmt) session = result.scalar_one_or_none() if session is not None and session.conversation_id is not None: return str(session.conversation_id) # FR-06 关联策略:按 unified_identity_id 查询已有跨渠道会话, # 使用 pg_advisory_xact_lock 串行化同一身份的关联操作,避免并发 # 创建重复会话。隔离策略(unified_identity_id 为空)跳过此步。 if cmd.unified_identity_id is not None: lock_key = zlib.crc32(cmd.unified_identity_id.encode("utf-8")) await self._db.execute( text("SELECT pg_advisory_xact_lock(:key)"), {"key": lock_key}, ) conversations = await self._conversation_repo.find_by_unified_identity(cmd.unified_identity_id) if conversations: return str(conversations[0].id) if not cmd.create_if_not_found: raise NotFoundError( "conversation", f"{cmd.peer_id}@{cmd.channel_type}", ) conversation = ConversationORM( thread_id=str(uuid.uuid4()), uid=cmd.user_id, agent_id=cmd.new_conversation_agent_id, title=cmd.new_conversation_title, status=cmd.new_conversation_status, extra_metadata=cmd.new_conversation_extra_metadata, unified_identity_id=cmd.unified_identity_id, channel_type=cmd.channel_type, channel_account_id=cmd.account_id, ) self._db.add(conversation) await self._db.flush() stats = ConversationStatsORM(conversation_id=conversation.id) self._db.add(stats) if self._should_commit(tx): await self._db.commit() await self._db.refresh(conversation) return str(conversation.id) except IntegrityError as exc: raise self._translate_db_error(exc, "conversation") from exc except SQLAlchemyError as exc: await self._logger.error( "conversation_resolve_conversation_failed", resource="conversation", error=str(exc), ) raise self._translate_db_error(exc, "conversation") from exc except Error: raise except Exception as exc: await self._logger.error( "conversation_resolve_conversation_unexpected_error", resource="conversation", error=str(exc), ) raise DependencyError("conversation", Error(str(exc))) from exc async def associateConversation( self, cmd: AssociateConversationCmd, tx: TransactionContext | None = None, ) -> None: """关联会话至统一身份与路由绑定。 使用 pg_advisory_xact_lock 串行化关联操作,避免并发关联冲突。 通过 unified_identity_id 查询已存在的会话,再调用 link_channel_session 写入渠道扩展字段。 Args: cmd: 关联会话命令,携带统一身份 ID 与渠道会话 ID。 tx: 事务上下文,非空时加入应用层事务,**不得** 自主提交。 Raises: NotFoundError: 会话或渠道会话不存在时抛出。 ConflictError: 并发冲突时抛出。 DependencyError: 数据库故障时抛出。 """ commit = self._should_commit(tx) try: lock_key = zlib.crc32(cmd.unified_identity_id.encode("utf-8")) await self._db.execute( text("SELECT pg_advisory_xact_lock(:key)"), {"key": lock_key}, ) conversations = await self._conversation_repo.find_by_unified_identity(cmd.unified_identity_id) if not conversations: raise NotFoundError("conversation", cmd.unified_identity_id) conversation = conversations[0] channel_type = conversation.channel_type or "" channel_account_id = conversation.channel_account_id or "" if cmd.channel_session_id is not None: session = await self._session_repo.get_by_session_id(cmd.channel_session_id) if session is None: raise NotFoundError("channel_session", cmd.channel_session_id) channel_type = channel_type or session.channel_type if not channel_account_id: # session.account_id 是 int FK(指向 channel_accounts.id 主键), # 需查询 ChannelAccount ORM 获取业务 account_id(str)。 account_stmt = select(ChannelAccountORM.account_id).where( ChannelAccountORM.id == session.account_id ) account_result = await self._db.execute(account_stmt) account_id_str = account_result.scalar_one_or_none() if account_id_str is not None: channel_account_id = account_id_str linked = await self._conversation_repo.link_channel_session( conversation.id, channel_session_id=cmd.channel_session_id or conversation.channel_session_id or "", unified_identity_id=cmd.unified_identity_id, channel_type=channel_type, channel_account_id=channel_account_id, commit=commit, ) if linked is None: raise NotFoundError("conversation", str(conversation.id)) except IntegrityError as exc: raise self._translate_db_error(exc, "conversation") from exc except SQLAlchemyError as exc: await self._logger.error( "conversation_associate_conversation_failed", resource="conversation", error=str(exc), ) raise self._translate_db_error(exc, "conversation") from exc except Error: raise except Exception as exc: await self._logger.error( "conversation_associate_conversation_unexpected_error", resource="conversation", error=str(exc), ) raise DependencyError("conversation", Error(str(exc))) from exc async def mergeConversations( self, cmd: MergeConversationCmd, tx: TransactionContext | None = None, ) -> MergeResult: """合并会话(破坏性操作,纯数据操作)。 本方法仅执行数据操作,业务规则(FR-26 主会话所有者保护)已上移至 ``SessionMerger.merge`` 领域服务,通过 ``listChannelSessionsByConversationId`` 加载 DTO 重建聚合根调用 ``markMerged()`` 校验后再委托本方法: 1. 使用 ``pg_advisory_xact_lock`` 串行化合并操作(按源会话 ID)。 2. 检测源会话已合并状态(``status='deleted'``),返回幂等结果。 3. 迁移源会话消息至目标会话。 4. 软删除源 Conversation(``status='deleted'``)与旧 ChannelSession。 合并策略开关与源/目标相同性校验已上移至 usecase 层: 前者由 ``DispatchStage._sessionMerge`` 在构造 ``MergeConversationCmd`` 前执行策略门,后者由 ``MergeConversationCmd.__post_init__`` 在构造时校验(INV-8)。 审计日志由控制面管道 audit 阶段统一记录,本适配器不直接写入审计 日志,返回的 ``audit_log_id`` 为 None。 幂等性:重复调用不会产生副作用(源会话已软删除时返回迁移数量 0)。 Args: cmd: 合并会话命令,包含源会话 ID、目标会话 ID、操作人与合并原因。 tx: 事务上下文,非空时加入应用层事务,**不得** 自主提交。 Returns: 合并结果,包含迁移消息数量与源会话软删除标记。 Raises: NotFoundError: 源会话 ID 或目标会话 ID 非法/不存在时抛出。 ValidationError: 由 ``MergeConversationCmd.__post_init__`` 在构造 时抛出(源会话 ID 与目标会话 ID 相同),不在本适配器抛出。 RuleViolationError: 由 ``SessionMerger.merge`` 调用 ``aggregate.markMerged()`` 在主会话所有者保护触发时抛出, 不在本适配器抛出。 ConflictError: 并发冲突时抛出。 DependencyError: 数据库故障时抛出。 """ commit = self._should_commit(tx) try: source_cid = int(cmd.source_conversation_id) except (TypeError, ValueError) as exc: raise NotFoundError("conversation", cmd.source_conversation_id) from exc try: target_cid = int(cmd.target_conversation_id) except (TypeError, ValueError) as exc: raise NotFoundError("conversation", cmd.target_conversation_id) from exc try: # 咨询锁串行化合并操作(按源会话 ID) lock_key = zlib.crc32(str(source_cid).encode("utf-8")) await self._db.execute( text("SELECT pg_advisory_xact_lock(:key)"), {"key": lock_key}, ) # 加载源会话与目标会话 source_conv = await self._db.get(ConversationORM, source_cid) if source_conv is None: raise NotFoundError("conversation", cmd.source_conversation_id) target_conv = await self._db.get(ConversationORM, target_cid) if target_conv is None: raise NotFoundError("conversation", cmd.target_conversation_id) # 幂等:源会话已软删除(已合并),返回幂等结果 if source_conv.status == "deleted": return MergeResult( migrated_message_count=0, source_soft_deleted=True, audit_log_id=None, ) # 业务规则校验(FR-26 主会话所有者保护)已在 SessionMerger 完成, # 本适配器仅执行纯数据操作(INV-8 适配器无业务规则)。 # sessions 供后续日志/事件等逻辑使用(H-9:软删除已改批量 UPDATE,不再依赖此列表) sessions = await self._session_repo.list_by_conversation(source_cid) # noqa: F841 # 迁移源会话消息至目标会话 msg_update_stmt = ( MessageORM.__table__.update() .where(MessageORM.conversation_id == source_cid) .values(conversation_id=target_cid) ) msg_result = await self._db.execute(msg_update_stmt) migrated_count = msg_result.rowcount or 0 # 软删除源 ChannelSession(批量 UPDATE,避免 N+1 逐条删除) now = utc_now_naive() await self._db.execute( sa_update(ChannelSessionORM) .where( ChannelSessionORM.conversation_id == source_cid, ChannelSessionORM.is_deleted == 0, ) .values(is_deleted=1, deleted_at=now, updated_at=now) ) # 软删除源 Conversation(status='deleted' 语义) source_conv.status = "deleted" source_conv.updated_at = utc_now_naive() if commit: await self._db.commit() return MergeResult( migrated_message_count=migrated_count, source_soft_deleted=True, audit_log_id=None, ) except IntegrityError as exc: raise self._translate_db_error(exc, "conversation") from exc except SQLAlchemyError as exc: await self._logger.error( "conversation_merge_conversations_failed", resource="conversation", error=str(exc), ) raise self._translate_db_error(exc, "conversation") from exc except Error: raise except Exception as exc: await self._logger.error( "conversation_merge_conversations_unexpected_error", resource="conversation", error=str(exc), ) raise DependencyError("conversation", Error(str(exc))) from exc async def getSessionOwner(self, conversation_id: str) -> SessionOwner | None: """查询会话所有者(会话所有者保护)。 通过 conversation_id 反查渠道会话,返回首个会话的所有者信息。 若无会话或会话未设置所有者,返回 None。 Args: conversation_id: 会话 ID。 Returns: SessionOwner 实例或 None。 Raises: NotFoundError: 会话 ID 非法时抛出。 ConflictError: 并发冲突时抛出。 DependencyError: 数据库故障时抛出。 """ try: cid = int(conversation_id) except ValueError as exc: raise NotFoundError("conversation", conversation_id) from exc try: sessions = await self._session_repo.list_by_conversation(cid, limit=1) if not sessions: return None session = sessions[0] if not session.owner_peer_id: return None return SessionOwner( conversation_id=conversation_id, owner_peer_id=session.owner_peer_id, created_at=session.created_at or utc_now_naive(), ) except IntegrityError as exc: raise self._translate_db_error(exc, "session") from exc except SQLAlchemyError as exc: await self._logger.error( "conversation_get_session_owner_failed", resource="session", error=str(exc), ) raise self._translate_db_error(exc, "session") from exc except Error: raise except Exception as exc: await self._logger.error( "conversation_get_session_owner_unexpected_error", resource="session", error=str(exc), ) raise DependencyError("session", Error(str(exc))) from exc async def transferSessionOwner( self, cmd: OwnerTransferCmd, tx: TransactionContext | None = None, ) -> SessionOwner: """转移会话所有者。 查询会话关联的渠道会话,更新其 owner_peer_id 字段。审计日志由 控制面管道 audit 阶段在 SHARED 事务中统一写入(fail-closed 语义), 本适配器不直接记录审计日志,避免双重审计。 Args: cmd: 所有者转移命令,携带会话 ID 与新所有者 ID。 tx: 事务上下文,非空时加入应用层事务,**不得** 自主提交。 Returns: 更新后的 SessionOwner 实例。 Raises: NotFoundError: 会话无渠道会话或会话 ID 非法时抛出。 ConflictError: 并发冲突时抛出。 DependencyError: 数据库故障时抛出。 """ commit = self._should_commit(tx) try: cid = int(cmd.conversation_id) except ValueError as exc: raise NotFoundError("conversation", cmd.conversation_id) from exc try: sessions = await self._session_repo.list_by_conversation(cid, limit=1) if not sessions: raise NotFoundError("channel_session", cmd.conversation_id) session = sessions[0] updated = await self._session_repo.update( session, {"owner_peer_id": cmd.new_owner_id}, commit=False, ) if commit: await self._db.commit() return SessionOwner( conversation_id=cmd.conversation_id, owner_peer_id=updated.owner_peer_id or cmd.new_owner_id, created_at=updated.created_at or utc_now_naive(), ) except IntegrityError as exc: raise self._translate_db_error(exc, "session") from exc except SQLAlchemyError as exc: await self._logger.error( "conversation_transfer_session_owner_failed", resource="session", error=str(exc), ) raise self._translate_db_error(exc, "session") from exc except Error: raise except Exception as exc: await self._logger.error( "conversation_transfer_session_owner_unexpected_error", resource="session", error=str(exc), ) raise DependencyError("session", Error(str(exc))) from exc async def isTemporarySession(self, conversation_id: str) -> bool: """检测是否为临时会话。 通过 conversation_id 反查渠道会话,返回首个会话的 is_temporary 标记。 若无会话返回 False;会话 ID 非法时抛 ``ValidationError``(INV-7 错误 显式化,禁止静默吞异常)。 Args: conversation_id: 会话 ID。 Returns: True 表示临时会话,False 表示常规会话或无会话。 Raises: ValidationError: 会话 ID 非法(int 转换失败)时抛出。 ConflictError: 并发冲突时抛出。 DependencyError: 数据库故障时抛出。 """ try: cid = int(conversation_id) except (TypeError, ValueError) as exc: raise ValidationError( "conversation_id", f"invalid conversation_id: {conversation_id}", ) from exc try: sessions = await self._session_repo.list_by_conversation(cid, limit=1) if not sessions: return False return bool(sessions[0].is_temporary) except IntegrityError as exc: raise self._translate_db_error(exc, "session") from exc except SQLAlchemyError as exc: await self._logger.error( "conversation_is_temporary_session_failed", resource="session", error=str(exc), ) raise self._translate_db_error(exc, "session") from exc except Error: raise except Exception as exc: await self._logger.error( "conversation_is_temporary_session_unexpected_error", resource="session", error=str(exc), ) raise DependencyError("session", Error(str(exc))) from exc async def appendOperationHistory( self, cmd: AppendOperationHistoryCmd, tx: TransactionContext | None = None, ) -> Message: """追加消息操作历史。 将操作历史条目追加到目标消息的 ``operations_history`` JSON 数组。 消息不存在时抛 ``NotFoundError``,禁止原生异常穿透至核心层。 Args: cmd: 追加操作历史命令,携带消息 ID 与操作历史条目。 tx: 事务上下文,非空时加入应用层事务,**不得** 自主提交。 Returns: 更新后的 Message 值对象。 Raises: NotFoundError: 消息不存在或消息 ID 非法时抛出。 ConflictError: 并发冲突时抛出。 DependencyError: 数据库故障时抛出。 """ commit = self._should_commit(tx) try: message_id = int(cmd.message_id) except ValueError as exc: raise NotFoundError("message", cmd.message_id) from exc try: updated = await self._message_repo.append_operation_history( message_id, cmd.entry, commit=commit, ) if updated is None: raise NotFoundError("message", cmd.message_id) return self._orm_to_message(updated) except IntegrityError as exc: raise self._translate_db_error(exc, "message") from exc except SQLAlchemyError as exc: await self._logger.error( "conversation_append_operation_history_failed", resource="message", error=str(exc), ) raise self._translate_db_error(exc, "message") from exc except Error: raise except Exception as exc: await self._logger.error( "conversation_append_operation_history_unexpected_error", resource="message", error=str(exc), ) raise DependencyError("message", Error(str(exc))) from exc async def getMessageById(self, message_id: str) -> Message | None: """按内部消息 ID 查询消息(MSG-01 / MSG-QUERY-02 / MSG-05 复用)。 按内部 ``message_id`` 定位消息完整状态,返回的 ``Message`` 含 ``channel_type`` 字段(通过 join ``ConversationORM`` 后由 ``_orm_to_message`` 填充),同时附加 ``channel_session_id`` / ``channel_account_id`` / ``peer_id`` / ``conversation_title`` 等 会话上下文。供 dispatch handler 的 ``message/get`` / ``message/status`` / ``message/recall`` 复用。 @pre message_id 非空 @post 返回匹配的 Message(含 ``channel_type`` 与上下文);不存在时返回 None @failure NotFoundError(message_id 非法)/ DependencyError @consistency 强一致性:返回当前持久化的消息快照 Args: message_id: 消息 ID(字符串形式的整数主键)。 Returns: 匹配的 Message 值对象;不存在时返回 None。 Raises: NotFoundError: message_id 非法时抛出。 ConflictError: 并发冲突时抛出。 DependencyError: 数据库故障时抛出。 """ try: message_id_int = int(message_id) except ValueError as exc: raise NotFoundError("message", message_id) from exc try: primary_session = self._primary_session_subq() stmt = ( select(MessageORM, ConversationORM, primary_session) .where(MessageORM.id == message_id_int) .join(ConversationORM, ConversationORM.id == MessageORM.conversation_id) .outerjoin(primary_session, true()) ) result = await self._db.execute(stmt) row = result.one_or_none() if row is None: return None message = self._orm_to_message(row.MessageORM) return self._attach_context(message, row.ConversationORM, row[2]) except IntegrityError as exc: raise self._translate_db_error(exc, "message") from exc except SQLAlchemyError as exc: await self._logger.error( "conversation_get_message_by_id_failed", resource="message", error=str(exc), ) raise self._translate_db_error(exc, "message") from exc except Error: raise except Exception as exc: await self._logger.error( "conversation_get_message_by_id_unexpected_error", resource="message", error=str(exc), ) raise DependencyError("message", Error(str(exc))) from exc async def listMessages( self, *, role: str | list[str] | tuple[str, ...] | None = None, channel_type: ChannelType | None = None, start_time: datetime | None = None, end_time: datetime | None = None, channel_status: str | list[str] | tuple[str, ...] | None = None, account_id: str | None = None, peer_id: str | None = None, message_id: str | None = None, channel_msg_id: str | None = None, channel_session_id: str | None = None, limit: int = 50, offset: int = 0, ) -> tuple[Message, ...]: """按条件查询消息列表(MSG-QUERY-01 运营审计用例)。 按 ``role`` / ``channel_type`` / 时间范围 / ``channel_status`` / 账户 / 对端 ID / 消息 ID / 渠道消息 ID / 渠道会话 ID 跨会话过滤消息,按 ``created_at`` 降序返回,支持 ``limit`` / ``offset`` 分页。每条 ``Message`` 含 ``channel_type`` 以及 ``channel_session_id`` / ``channel_account_id`` / ``peer_id`` / ``conversation_title``(通过 join ``ConversationORM`` 与 LATERAL 主渠道会话子查询填充)。 ``role`` 与 ``channel_status`` 支持单值字符串或字符串列表/元组。 职责分离(与 ``getMessages`` 区别):``getMessages`` 按指定 ``conversation_id`` 查询会话内消息(用于会话详情页),本方法按 ``role`` / ``channel_type`` 跨会话过滤(用于管理员发送历史等运营 审计场景),二者查询维度不同,故独立声明而非重载 ``getMessages``。 @pre limit > 0 且 <= 200;offset >= 0 @post 返回 Message 元组,按 ``created_at`` 降序排列,跳过前 offset 条; 每条 Message 含 ``channel_type`` 与上下文字段 @failure DependencyError @consistency 强一致性:返回当前持久化的消息快照 Args: role: 角色(可选,非空时按角色过滤,如 ``"admin"``)。 channel_type: 渠道类型(可选,非空时按渠道过滤)。 start_time: 起始时间(可选,含边界)。 end_time: 截止时间(可选,含边界)。 channel_status: 渠道状态(可选,非空时按状态过滤,支持多值)。 account_id: 渠道账户业务 ID(可选,模糊匹配)。 peer_id: 对端 ID(可选,模糊匹配)。 message_id: 消息业务 ID(可选,模糊匹配)。 channel_msg_id: 渠道消息 ID(可选,模糊匹配)。 channel_session_id: 渠道会话 ID(可选,模糊匹配)。 limit: 返回数量上限,默认 50。 offset: 分页偏移量,默认 0。 Returns: Message 元组,按 ``created_at`` 降序排列。 Raises: ConflictError: 并发冲突时抛出。 DependencyError: 数据库故障时抛出。 """ try: # DB 时间列为 naive UTC,剥离 DTO 入参的 tzinfo(见 _to_naive_utc 说明)。 start_naive = _to_naive_utc(start_time) end_naive = _to_naive_utc(end_time) primary_session = self._primary_session_subq() stmt = ( select(MessageORM, ConversationORM, primary_session) .join(ConversationORM, ConversationORM.id == MessageORM.conversation_id) .outerjoin(primary_session, true()) ) stmt = self._apply_message_filters( stmt, channel_type=channel_type, role=role, start_naive=start_naive, end_naive=end_naive, channel_status=channel_status, account_id=account_id, peer_id=peer_id, message_id=message_id, channel_msg_id=channel_msg_id, channel_session_id=channel_session_id, ) stmt = stmt.order_by(MessageORM.created_at.desc()).limit(limit).offset(offset) result = await self._db.execute(stmt) messages: list[Message] = [] for row in result.all(): message = self._orm_to_message(row.MessageORM) self._attach_context(message, row.ConversationORM, row[2]) messages.append(message) return tuple(messages) except IntegrityError as exc: raise self._translate_db_error(exc, "message") from exc except SQLAlchemyError as exc: await self._logger.error( "conversation_list_messages_failed", resource="message", error=str(exc), ) raise self._translate_db_error(exc, "message") from exc except Error: raise except Exception as exc: await self._logger.error( "conversation_list_messages_unexpected_error", resource="message", error=str(exc), ) raise DependencyError("message", Error(str(exc))) from exc async def countMessagesByRole( self, *, role: str | list[str] | tuple[str, ...] | None = None, channel_type: ChannelType | None = None, start_time: datetime | None = None, end_time: datetime | None = None, channel_status: str | list[str] | tuple[str, ...] | None = None, account_id: str | None = None, peer_id: str | None = None, message_id: str | None = None, channel_msg_id: str | None = None, channel_session_id: str | None = None, ) -> int: """按条件统计消息总数(MSG-QUERY-01 分页用例)。 返回满足 ``role`` / ``channel_type`` / 时间范围 / ``channel_status`` / 账户 / 对端 ID / 消息 ID / 渠道消息 ID / 渠道会话 ID 过滤条件的消息 总数,供分页响应计算总页数。过滤条件与 ``listMessages`` 保持一致。 命名分离原因:现有 ``countMessages`` 签名为 ``(conversation_id, start_time, end_time, channel_type)``(按会话 统计),本方法签名为 ``(role, channel_type, start_time, end_time)``(按角色统计)。Python Protocol 不支持同名方法重载共存,故命名为 ``countMessagesByRole`` 以避免签名冲突,同时语义清晰表达"按角色 统计"的查询维度。 @pre 无(所有过滤条件均可选) @post 返回满足条件的消息总数 @failure DependencyError @consistency 强一致性:返回当前持久化的消息快照 Args: role: 角色(可选,非空时按角色过滤,支持多值)。 channel_type: 渠道类型(可选,非空时按渠道过滤)。 start_time: 起始时间(可选,含边界)。 end_time: 截止时间(可选,含边界)。 channel_status: 渠道状态(可选,非空时按状态过滤,支持多值)。 account_id: 渠道账户业务 ID(可选,模糊匹配)。 peer_id: 对端 ID(可选,模糊匹配)。 message_id: 消息业务 ID(可选,模糊匹配)。 channel_msg_id: 渠道消息 ID(可选,模糊匹配)。 channel_session_id: 渠道会话 ID(可选,模糊匹配)。 Returns: 满足条件的消息总数。 Raises: ConflictError: 并发冲突时抛出。 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(func.count()) .select_from(MessageORM) .join( ConversationORM, ConversationORM.id == MessageORM.conversation_id, ) ) stmt = self._apply_message_filters( stmt, channel_type=channel_type, role=role, start_naive=start_naive, end_naive=end_naive, channel_status=channel_status, account_id=account_id, peer_id=peer_id, message_id=message_id, channel_msg_id=channel_msg_id, channel_session_id=channel_session_id, ) result = await self._db.execute(stmt) return result.scalar_one() except IntegrityError as exc: raise self._translate_db_error(exc, "message") from exc except SQLAlchemyError as exc: await self._logger.error( "conversation_count_messages_by_role_failed", resource="message", error=str(exc), ) raise self._translate_db_error(exc, "message") from exc except Error: raise except Exception as exc: await self._logger.error( "conversation_count_messages_by_role_unexpected_error", resource="message", error=str(exc), ) raise DependencyError("message", Error(str(exc))) from exc async def getMessageStats( self, channel_type: ChannelType | None = None, *, start_time: datetime | None = None, end_time: datetime | None = None, ) -> MessageStats: """实现 ConversationPort.getMessageStats。 通过 join ConversationORM + GROUP BY channel_type, role, channel_status 单次查询完成聚合。channel_type 取自会话关联填充。 """ 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( ConversationORM.channel_type, MessageORM.role, MessageORM.channel_status, func.count().label("count"), ) .join(ConversationORM, ConversationORM.id == MessageORM.conversation_id) .group_by( ConversationORM.channel_type, MessageORM.role, MessageORM.channel_status, ) ) if start_naive is not None: stmt = stmt.where(MessageORM.created_at >= start_naive) if end_naive is not None: stmt = stmt.where(MessageORM.created_at <= end_naive) if channel_type is not None: stmt = stmt.where(ConversationORM.channel_type == channel_type) result = await self._db.execute(stmt) by_channel: dict[str, int] = {} by_role: dict[str, int] = {} # 预初始化已知投递状态值域(设计方案 §11.1), # NULL channel_status(未投递消息)不计入 by_delivery_status 但计入 total by_delivery_status: dict[str, int] = { "sent": 0, "delivered": 0, "read": 0, "recalled": 0, "edited": 0, } total = 0 for row in result.all(): ch = row.channel_type or "unknown" role = row.role or "unknown" status = row.channel_status cnt = int(row.count) by_channel[ch] = by_channel.get(ch, 0) + cnt by_role[role] = by_role.get(role, 0) + cnt if status in by_delivery_status: by_delivery_status[status] += cnt total += cnt return MessageStats( total=total, 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: raise DependencyError("message", Error(str(exc))) from exc async def markMessageRecalled( self, message_id: str, recalled_at: datetime, *, tx: TransactionContext | None = None, ) -> None: """标记消息撤回(MSG-05 持久化用例,FR-12)。 将消息 ``channel_status`` 更新为 ``"recalled"``,填充 ``channel_recalled_at`` 时间戳,并向 ``channel_status_history`` 追加 ``{"status": "recalled", "at": recalled_at.isoformat()}`` 事件。 复用 ``ChannelMessageRepository.update_channel_status`` 的 ``recalled_at`` / ``channel_status`` / ``event`` / ``commit`` 参数, 无需新增 repo 方法。repo 内部使用 ``flag_modified`` 标记 ``channel_status_history`` JSON 字段变更以确保持久化。 事务感知语义:接收可选 ``tx`` 参数,``tx`` 非空时加入应用层事务 (``commit=False``,由编排器统一提交,**不得** 自主提交);``tx`` 为 ``None`` 时按单方法提交(``commit=True``,向后兼容)。 @pre message_id 已存在;recalled_at 非空 @post ``channel_status`` 更新为 ``"recalled"``;``channel_recalled_at`` 已填充;``channel_status_history`` 已追加事件 @failure NotFoundError(消息不存在)/ DependencyError @consistency 强一致性:事务内写入,``tx`` 非空时由应用层统一提交 Args: message_id: 消息 ID(字符串形式的整数主键)。 recalled_at: 撤回时间戳(UTC)。 tx: 事务上下文,非空时加入应用层事务,**不得** 自主提交。 Raises: NotFoundError: 消息不存在或消息 ID 非法时抛出。 ConflictError: 并发冲突时抛出。 DependencyError: 数据库故障时抛出。 """ commit = self._should_commit(tx) try: message_id_int = int(message_id) except ValueError as exc: raise NotFoundError("message", message_id) from exc try: updated = await self._message_repo.update_channel_status( message_id_int, channel_status="recalled", event={"status": "recalled", "at": recalled_at.isoformat()}, recalled_at=recalled_at, commit=commit, ) if updated is None: raise NotFoundError("message", message_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 async def searchMessages( self, keyword: str, *, channel_type: ChannelType | None = None, channel_status: str | list[str] | tuple[str, ...] | None = None, role: str | list[str] | tuple[str, ...] | None = None, channel_session_id: str | None = None, peer_id: str | None = None, message_id: str | None = None, channel_msg_id: str | None = None, start_time: datetime | None = None, end_time: datetime | None = None, limit: int = 50, offset: int = 0, ) -> tuple[MessageSearchItem, ...]: """全文搜索消息(MSG-SEARCH-01)。 按关键词搜索消息内容,支持按渠道、渠道侧状态、角色、渠道会话、对端 ID、消息 ID、渠道消息 ID、时间范围过滤。返回 ``MessageSearchItem`` 含 ``channel_session_id`` / ``channel_account_id`` / ``peer_id`` / ``conversation_title`` 等会话上下文字段。 当前实现使用 ``content.ilike`` 子串匹配;项目若后续添加 PostgreSQL tsvector 索引,可在此替换为 ``to_tsvector`` / ``plainto_tsquery`` 而不改变端口签名。 """ try: start_naive = _to_naive_utc(start_time) end_naive = _to_naive_utc(end_time) primary_session = self._primary_session_subq() stmt = ( select(MessageORM, ConversationORM, primary_session) .join(ConversationORM, ConversationORM.id == MessageORM.conversation_id) .outerjoin(primary_session, true()) .where(MessageORM.content.ilike(f"%{keyword}%")) ) stmt = self._apply_message_filters( stmt, channel_type=channel_type, role=role, start_naive=start_naive, end_naive=end_naive, channel_status=channel_status, account_id=None, peer_id=peer_id, message_id=message_id, channel_msg_id=channel_msg_id, channel_session_id=channel_session_id, ) stmt = stmt.order_by(MessageORM.created_at.desc()).limit(limit).offset(offset) result = await self._db.execute(stmt) items: list[MessageSearchItem] = [] for row in result.all(): msg = row.MessageORM conv = row.ConversationORM sess = row[2] items.append( MessageSearchItem( message_id=str(msg.id), conversation_id=str(msg.conversation_id), channel_type=ChannelType(conv.channel_type) if conv is not None and conv.channel_type else ChannelType(""), role=msg.role, content=msg.content, created_at=msg.created_at, channel_session_id=getattr(sess, "session_id", None) if sess is not None else None, channel_account_id=getattr(sess, "channel_account_id", None) if sess is not None else None, peer_id=getattr(sess, "peer_id", None) if sess is not None else None, conversation_title=getattr(conv, "title", None) if conv is not None else None, ) ) return tuple(items) 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 async def countSearchMessages( self, keyword: str, *, channel_type: ChannelType | None = None, channel_status: str | list[str] | tuple[str, ...] | None = None, role: str | list[str] | tuple[str, ...] | None = None, channel_session_id: str | None = None, peer_id: str | None = None, message_id: str | None = None, channel_msg_id: str | None = None, start_time: datetime | None = None, end_time: datetime | None = None, ) -> int: """统计全文搜索匹配消息总数(MSG-SEARCH-01 分页用例)。 过滤条件与 ``searchMessages`` 保持一致。 """ try: start_naive = _to_naive_utc(start_time) end_naive = _to_naive_utc(end_time) stmt = ( select(func.count()) .select_from(MessageORM) .join(ConversationORM, ConversationORM.id == MessageORM.conversation_id) .where(MessageORM.content.ilike(f"%{keyword}%")) ) stmt = self._apply_message_filters( stmt, channel_type=channel_type, role=role, start_naive=start_naive, end_naive=end_naive, channel_status=channel_status, account_id=None, peer_id=peer_id, message_id=message_id, channel_msg_id=channel_msg_id, channel_session_id=channel_session_id, ) result = await self._db.execute(stmt) return result.scalar_one() 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