ForcePilot/backend/package/yuxi/channels/adapters/conversation_adapter.py

1788 lines
76 KiB
Python
Raw Normal View History

"""ConversationAdapter实现 ConversationPort复用现有 Conversation/Message 表。
- 必须复用现有 Conversation/Message
- 不得新建渠道专属消息/会话表
- 咨询锁必须复用 PostgreSQL pg_advisory_xact_lock
- 会话合并必须幂等可回滚失败时保留原数据并记录审计日志
- 事务边界写操作接受可选 ``tx`` 参数TransactionContext
``tx`` 非空时加入应用层事务**不得** 自主提交``tx`` ``None``
按单方法提交向后兼容
依赖边界只依赖 yuxi.channels.contract端口 + DTO + 错误
yuxi.repositories.channelsChannelConversationRepository / ChannelMessageRepository /
ChannelSessionRepositoryyuxi.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.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)
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_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 = await self._session_repo.list_by_conversation(source_cid)
# 迁移源会话消息至目标会话
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业务规则已在 SessionMerger 校验)
for session_orm in sessions:
await self._session_repo.delete_by_id(session_orm.id, commit=False)
# 软删除源 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)
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)
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)
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