ForcePilot/backend/package/yuxi/channels/adapters/conversation_adapter.py
Kris 8eead29de0 refactor: 批量清理冗余空行,优化部分枚举使用方式
1.  移除所有适配器文件中多余的空导入行
2.  调整ValidationError继承,移除不必要的ValueError继承
3.  修正多处ChannelType使用方式,从.value改为直接使用枚举实例
4.  优化飞书插件部分硬编码渠道类型为枚举实例
5.  更新wechat_ilink插件清单与适配器配置
6.  新增飞书目录适配器缓存清理支持判断与iLink生命周期适配器凭据轮换支持判断
7.  优化配置处理器历史查询逻辑,区分键不存在与无历史记录场景
2026-07-04 00:14:56 +08:00

1527 lines
64 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
from sqlalchemy import func, select, text
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 _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:
"""保存消息(持久化投递)。
消息本体由应用服务层创建,本方法负责定位会话最新消息并初始化其渠道
扩展字段。若会话无消息则抛 NotFoundError。
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:
stmt = (
select(MessageORM)
.where(MessageORM.conversation_id == conversation_id)
.order_by(MessageORM.created_at.desc())
.limit(1)
)
result = await self._db.execute(stmt)
latest = result.scalar_one_or_none()
if latest is None:
raise NotFoundError("message", cmd.conversation_id)
updated = await self._message_repo.init_channel_fields(
latest.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 updated is None:
raise NotFoundError("message", str(latest.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_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 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",
f"invalid conversation_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",
f"invalid conversation_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`` 字段(通过 ``selectinload`` 预加载 ``conversation``
relationship 后由 ``_orm_to_message`` 填充)。供 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:
stmt = (
select(MessageORM).where(MessageORM.id == message_id_int).options(selectinload(MessageORM.conversation))
)
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_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 | None = None,
channel_type: ChannelType | None = None,
start_time: datetime | None = None,
end_time: datetime | None = None,
channel_status: str | None = None,
limit: int = 50,
offset: int = 0,
) -> tuple[Message, ...]:
"""按条件查询消息列表MSG-QUERY-01 运营审计用例)。
按 ``role`` / ``channel_type`` / 时间范围 / ``channel_status`` 跨会话过滤消息,按
``created_at`` 降序返回,支持 ``limit`` / ``offset`` 分页。每条
``Message`` 含 ``channel_type``(通过 ``selectinload`` 预加载
``conversation`` relationship 填充)。
职责分离(与 ``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: 渠道状态(可选,非空时按状态过滤)。
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)
stmt = select(MessageORM).options(selectinload(MessageORM.conversation))
if channel_type is not None:
stmt = stmt.join(
ConversationORM,
ConversationORM.id == MessageORM.conversation_id,
).where(ConversationORM.channel_type == channel_type)
if 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 channel_status is not None:
stmt = stmt.where(MessageORM.channel_status == channel_status)
stmt = stmt.order_by(MessageORM.created_at.desc()).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_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 | None = None,
channel_type: ChannelType | None = None,
start_time: datetime | None = None,
end_time: datetime | None = None,
channel_status: str | None = None,
) -> int:
"""按条件统计消息总数MSG-QUERY-01 分页用例)。
返回满足 ``role`` / ``channel_type`` / 时间范围 / ``channel_status``
过滤条件的消息总数,供分页响应计算总页数。过滤条件与 ``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: 渠道状态(可选,非空时按状态过滤)。
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,
)
)
if role is not None:
stmt = stmt.where(MessageORM.role == role)
if channel_type is not None:
stmt = stmt.where(ConversationORM.channel_type == channel_type)
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_status is not None:
stmt = stmt.where(MessageORM.channel_status == channel_status)
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_session_id: str | None = None,
peer_id: str | None = None,
start_time: datetime | None = None,
end_time: datetime | None = None,
limit: int = 50,
offset: int = 0,
) -> tuple[MessageSearchItem, ...]:
try:
stmt = (
select(MessageORM)
.options(selectinload(MessageORM.conversation))
.where(MessageORM.content.ilike(f"%{keyword}%"))
)
if channel_session_id is not None:
stmt = stmt.join(
ChannelSessionORM,
ChannelSessionORM.conversation_id == MessageORM.conversation_id,
).where(
ChannelSessionORM.session_id == channel_session_id,
ChannelSessionORM.is_deleted == 0,
)
if peer_id is not None:
stmt = stmt.where(ChannelSessionORM.peer_id == peer_id)
elif peer_id is not None:
stmt = stmt.join(
ChannelSessionORM,
ChannelSessionORM.conversation_id == MessageORM.conversation_id,
).where(
ChannelSessionORM.peer_id == peer_id,
ChannelSessionORM.is_deleted == 0,
)
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.desc()).limit(limit).offset(offset)
result = await self._db.execute(stmt)
orms = result.scalars().all()
items: list[MessageSearchItem] = []
for orm in orms:
session_id_val: str | None = None
if channel_session_id is not None:
session_id_val = channel_session_id
items.append(
MessageSearchItem(
message_id=str(orm.id),
conversation_id=str(orm.conversation_id),
channel_session_id=session_id_val,
channel_type=ChannelType(orm.conversation.channel_type)
if orm.conversation and orm.conversation.channel_type
else ChannelType(""),
role=orm.role,
content=orm.content,
created_at=orm.created_at,
)
)
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_session_id: str | None = None,
peer_id: str | None = None,
start_time: datetime | None = None,
end_time: datetime | None = None,
) -> int:
try:
stmt = select(func.count()).select_from(MessageORM).where(MessageORM.content.ilike(f"%{keyword}%"))
if channel_session_id is not None:
stmt = stmt.join(
ChannelSessionORM,
ChannelSessionORM.conversation_id == MessageORM.conversation_id,
).where(
ChannelSessionORM.session_id == channel_session_id,
ChannelSessionORM.is_deleted == 0,
)
if peer_id is not None:
stmt = stmt.where(ChannelSessionORM.peer_id == peer_id)
elif peer_id is not None:
stmt = stmt.join(
ChannelSessionORM,
ChannelSessionORM.conversation_id == MessageORM.conversation_id,
).where(
ChannelSessionORM.peer_id == peer_id,
ChannelSessionORM.is_deleted == 0,
)
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:
raise self._translate_db_error(exc, "message") from exc