ForcePilot/backend/package/yuxi/channels/adapters/conversation_adapter.py
Kris bb1934023e refactor: 完成会话持久化与事务机制重构,清理冗余代码
本次提交是一次大型架构重构,核心变更包括:
1. 调整持久化适配器为无状态实现,通过session_factory按需获取会话
2. 重构事务共享与透传机制,统一使用_session_scope管理会话生命周期
3. 移除OutboxEntry聚合根内的版本自增逻辑,由持久化层统一管理
4. 优化微信插件会话类型枚举与配置对齐
5. 简化出站管道阶段依赖注入与流程逻辑
6. 删除健康检查自动释放会话的冗余代码
7. 重构多个定时任务处理器,移除显式会话工厂创建逻辑
8. 修复会话延迟加载异常问题,新增时区转换工具函数
2026-07-10 04:10:33 +08:00

1886 lines
80 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""ConversationAdapter实现 ConversationPort复用现有 Conversation/Message 表。
- 必须复用现有 Conversation/Message 表
- 不得新建渠道专属消息/会话表
- 咨询锁必须复用 PostgreSQL pg_advisory_xact_lock
- 会话合并必须幂等可回滚,失败时保留原数据并记录审计日志
- 事务边界:写操作接受可选 ``tx`` 参数TransactionContext
``tx`` 非空时加入应用层事务,**不得** 自主提交;``tx`` 为 ``None`` 时
按单方法提交(向后兼容)。
依赖边界:只依赖 yuxi.channels.contract端口 + DTO + 错误)、
yuxi.repositories.channelsChannelConversationRepository / ChannelMessageRepository /
ChannelSessionRepository、yuxi.storage.postgres.managerpg_manager
sqlalchemypg_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 抛
# MissingGreenletSQLAlchemyError 子类)。
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)
# update_channel_status 用 db.get 加载 Message不预加载
# conversation relationshipcommit 后expire_on_commit=True
# 关系过期_orm_to_message 访问 orm.conversation.channel_type 会
# 触发 lazy-load 抛 MissingGreenletSQLAlchemyError 子类)。需显式
# 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
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:
# 预加载 conversation relationship_orm_to_message 访问
# orm.conversation.channel_typeasync SQLAlchemy 中未预加载会
# 触发 lazy-load 抛 MissingGreenlet。
stmt = (
select(MessageORM)
.options(selectinload(MessageORM.conversation))
.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:
# 预加载 conversation relationship避免 _orm_to_message 触发
# lazy-load 抛 MissingGreenlet见 getMessages 同款处理)。
stmt = (
select(MessageORM)
.options(selectinload(MessageORM.conversation))
.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 updateConversationAgentId(
self,
conversation_id: str,
agent_id: str,
tx: TransactionContext | None = None,
) -> None:
"""更新会话绑定的 Agent IDFR-04 路由绑定回写)。
仅在 ``conversation.agent_id`` 为空时允许首次绑定;已绑定且与
``agent_id`` 不一致时抛 ``ConflictError``。
Args:
conversation_id: 会话 ID数字主键字符串
agent_id: 目标 Agent slug。
tx: 事务上下文,非空时加入应用层事务,**不得** 自主提交。
Raises:
ValidationError: conversation_id 非法或 agent_id 为空。
NotFoundError: conversation 不存在。
ConflictError: conversation 已绑定其他 agent。
DependencyError: 数据库故障。
"""
if not conversation_id:
raise ValidationError("conversation_id", "must not be empty")
if not agent_id:
raise ValidationError("agent_id", "must not be empty")
try:
cid = int(conversation_id)
except (TypeError, ValueError) as exc:
raise ValidationError("conversation_id", f"invalid conversation_id: {conversation_id}") from exc
db = self._db
try:
conversation = await db.get(ConversationORM, cid)
if conversation is None:
raise NotFoundError("conversation", f"conversation not found: id={cid}")
if conversation.agent_id and conversation.agent_id != agent_id:
raise ConflictError(
"conversation",
f"conversation {cid} already bound to agent "
f"'{conversation.agent_id}', cannot switch to '{agent_id}'",
)
if not conversation.agent_id:
conversation.agent_id = agent_id
await db.flush()
if self._should_commit(tx):
await db.commit()
except (ValidationError, NotFoundError, ConflictError):
raise
except SQLAlchemyError as exc:
await self._logger.error(
"conversation_update_agent_id_failed",
resource="conversation",
conversation_id=conversation_id,
agent_id=agent_id,
error=str(exc),
)
raise self._translate_db_error(exc, "conversation") from exc
except Error:
raise
except Exception as exc:
await self._logger.error(
"conversation_update_agent_id_unexpected_error",
resource="conversation",
conversation_id=conversation_id,
agent_id=agent_id,
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_idstr
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)
)
# 软删除源 Conversationstatus='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 NotFoundErrormessage_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 且 <= 200offset >= 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