1. 移除所有适配器文件中多余的空导入行 2. 调整ValidationError继承,移除不必要的ValueError继承 3. 修正多处ChannelType使用方式,从.value改为直接使用枚举实例 4. 优化飞书插件部分硬编码渠道类型为枚举实例 5. 更新wechat_ilink插件清单与适配器配置 6. 新增飞书目录适配器缓存清理支持判断与iLink生命周期适配器凭据轮换支持判断 7. 优化配置处理器历史查询逻辑,区分键不存在与无历史记录场景
1527 lines
64 KiB
Python
1527 lines
64 KiB
Python
"""ConversationAdapter:实现 ConversationPort,复用现有 Conversation/Message 表。
|
||
|
||
- 必须复用现有 Conversation/Message 表
|
||
- 不得新建渠道专属消息/会话表
|
||
- 咨询锁必须复用 PostgreSQL pg_advisory_xact_lock
|
||
- 会话合并必须幂等可回滚,失败时保留原数据并记录审计日志
|
||
- 事务边界:写操作接受可选 ``tx`` 参数(TransactionContext),
|
||
``tx`` 非空时加入应用层事务,**不得** 自主提交;``tx`` 为 ``None`` 时
|
||
按单方法提交(向后兼容)。
|
||
|
||
依赖边界:只依赖 yuxi.channels.contract(端口 + DTO + 错误)、
|
||
yuxi.repositories.channels(ChannelConversationRepository / ChannelMessageRepository /
|
||
ChannelSessionRepository)、yuxi.storage.postgres.manager(pg_manager)、
|
||
sqlalchemy(pg_advisory_xact_lock)。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import uuid
|
||
import zlib
|
||
from datetime import datetime
|
||
from typing import TYPE_CHECKING
|
||
|
||
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_id(str)。
|
||
account_stmt = select(ChannelAccountORM.account_id).where(
|
||
ChannelAccountORM.id == session.account_id
|
||
)
|
||
account_result = await self._db.execute(account_stmt)
|
||
account_id_str = account_result.scalar_one_or_none()
|
||
if account_id_str is not None:
|
||
channel_account_id = account_id_str
|
||
linked = await self._conversation_repo.link_channel_session(
|
||
conversation.id,
|
||
channel_session_id=cmd.channel_session_id or conversation.channel_session_id or "",
|
||
unified_identity_id=cmd.unified_identity_id,
|
||
channel_type=channel_type,
|
||
channel_account_id=channel_account_id,
|
||
commit=commit,
|
||
)
|
||
if linked is None:
|
||
raise NotFoundError("conversation", str(conversation.id))
|
||
except IntegrityError as exc:
|
||
raise self._translate_db_error(exc, "conversation") from exc
|
||
except SQLAlchemyError as exc:
|
||
await self._logger.error(
|
||
"conversation_associate_conversation_failed",
|
||
resource="conversation",
|
||
error=str(exc),
|
||
)
|
||
raise self._translate_db_error(exc, "conversation") from exc
|
||
except Error:
|
||
raise
|
||
except Exception as exc:
|
||
await self._logger.error(
|
||
"conversation_associate_conversation_unexpected_error",
|
||
resource="conversation",
|
||
error=str(exc),
|
||
)
|
||
raise DependencyError("conversation", Error(str(exc))) from exc
|
||
|
||
async def mergeConversations(
|
||
self,
|
||
cmd: MergeConversationCmd,
|
||
tx: TransactionContext | None = None,
|
||
) -> MergeResult:
|
||
"""合并会话(破坏性操作,纯数据操作)。
|
||
|
||
本方法仅执行数据操作,业务规则(FR-26 主会话所有者保护)已上移至
|
||
``SessionMerger.merge`` 领域服务,通过 ``listChannelSessionsByConversationId``
|
||
加载 DTO 重建聚合根调用 ``markMerged()`` 校验后再委托本方法:
|
||
|
||
1. 使用 ``pg_advisory_xact_lock`` 串行化合并操作(按源会话 ID)。
|
||
2. 检测源会话已合并状态(``status='deleted'``),返回幂等结果。
|
||
3. 迁移源会话消息至目标会话。
|
||
4. 软删除源 Conversation(``status='deleted'``)与旧 ChannelSession。
|
||
|
||
合并策略开关与源/目标相同性校验已上移至 usecase 层:
|
||
前者由 ``DispatchStage._sessionMerge`` 在构造
|
||
``MergeConversationCmd`` 前执行策略门,后者由
|
||
``MergeConversationCmd.__post_init__`` 在构造时校验(INV-8)。
|
||
|
||
审计日志由控制面管道 audit 阶段统一记录,本适配器不直接写入审计
|
||
日志,返回的 ``audit_log_id`` 为 None。
|
||
|
||
幂等性:重复调用不会产生副作用(源会话已软删除时返回迁移数量 0)。
|
||
|
||
Args:
|
||
cmd: 合并会话命令,包含源会话 ID、目标会话 ID、操作人与合并原因。
|
||
tx: 事务上下文,非空时加入应用层事务,**不得** 自主提交。
|
||
|
||
Returns:
|
||
合并结果,包含迁移消息数量与源会话软删除标记。
|
||
|
||
Raises:
|
||
NotFoundError: 源会话 ID 或目标会话 ID 非法/不存在时抛出。
|
||
ValidationError: 由 ``MergeConversationCmd.__post_init__`` 在构造
|
||
时抛出(源会话 ID 与目标会话 ID 相同),不在本适配器抛出。
|
||
RuleViolationError: 由 ``SessionMerger.merge`` 调用
|
||
``aggregate.markMerged()`` 在主会话所有者保护触发时抛出,
|
||
不在本适配器抛出。
|
||
ConflictError: 并发冲突时抛出。
|
||
DependencyError: 数据库故障时抛出。
|
||
"""
|
||
commit = self._should_commit(tx)
|
||
try:
|
||
source_cid = int(cmd.source_conversation_id)
|
||
except (TypeError, ValueError) as exc:
|
||
raise NotFoundError("conversation", cmd.source_conversation_id) from exc
|
||
try:
|
||
target_cid = int(cmd.target_conversation_id)
|
||
except (TypeError, ValueError) as exc:
|
||
raise NotFoundError("conversation", cmd.target_conversation_id) from exc
|
||
try:
|
||
# 咨询锁串行化合并操作(按源会话 ID)
|
||
lock_key = zlib.crc32(str(source_cid).encode("utf-8"))
|
||
await self._db.execute(
|
||
text("SELECT pg_advisory_xact_lock(:key)"),
|
||
{"key": lock_key},
|
||
)
|
||
|
||
# 加载源会话与目标会话
|
||
source_conv = await self._db.get(ConversationORM, source_cid)
|
||
if source_conv is None:
|
||
raise NotFoundError("conversation", cmd.source_conversation_id)
|
||
target_conv = await self._db.get(ConversationORM, target_cid)
|
||
if target_conv is None:
|
||
raise NotFoundError("conversation", cmd.target_conversation_id)
|
||
|
||
# 幂等:源会话已软删除(已合并),返回幂等结果
|
||
if source_conv.status == "deleted":
|
||
return MergeResult(
|
||
migrated_message_count=0,
|
||
source_soft_deleted=True,
|
||
audit_log_id=None,
|
||
)
|
||
|
||
# 业务规则校验(FR-26 主会话所有者保护)已在 SessionMerger 完成,
|
||
# 本适配器仅执行纯数据操作(INV-8 适配器无业务规则)。
|
||
sessions = 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)
|
||
|
||
# 软删除源 Conversation(status='deleted' 语义)
|
||
source_conv.status = "deleted"
|
||
source_conv.updated_at = utc_now_naive()
|
||
|
||
if commit:
|
||
await self._db.commit()
|
||
return MergeResult(
|
||
migrated_message_count=migrated_count,
|
||
source_soft_deleted=True,
|
||
audit_log_id=None,
|
||
)
|
||
except IntegrityError as exc:
|
||
raise self._translate_db_error(exc, "conversation") from exc
|
||
except SQLAlchemyError as exc:
|
||
await self._logger.error(
|
||
"conversation_merge_conversations_failed",
|
||
resource="conversation",
|
||
error=str(exc),
|
||
)
|
||
raise self._translate_db_error(exc, "conversation") from exc
|
||
except Error:
|
||
raise
|
||
except Exception as exc:
|
||
await self._logger.error(
|
||
"conversation_merge_conversations_unexpected_error",
|
||
resource="conversation",
|
||
error=str(exc),
|
||
)
|
||
raise DependencyError("conversation", Error(str(exc))) from exc
|
||
|
||
async def getSessionOwner(self, conversation_id: str) -> SessionOwner | None:
|
||
"""查询会话所有者(会话所有者保护)。
|
||
|
||
通过 conversation_id 反查渠道会话,返回首个会话的所有者信息。
|
||
若无会话或会话未设置所有者,返回 None。
|
||
|
||
Args:
|
||
conversation_id: 会话 ID。
|
||
|
||
Returns:
|
||
SessionOwner 实例或 None。
|
||
|
||
Raises:
|
||
NotFoundError: 会话 ID 非法时抛出。
|
||
ConflictError: 并发冲突时抛出。
|
||
DependencyError: 数据库故障时抛出。
|
||
"""
|
||
try:
|
||
cid = int(conversation_id)
|
||
except ValueError as exc:
|
||
raise NotFoundError("conversation", conversation_id) from exc
|
||
try:
|
||
sessions = await self._session_repo.list_by_conversation(cid)
|
||
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 NotFoundError(message_id 非法)/ DependencyError
|
||
@consistency 强一致性:返回当前持久化的消息快照
|
||
|
||
Args:
|
||
message_id: 消息 ID(字符串形式的整数主键)。
|
||
|
||
Returns:
|
||
匹配的 Message 值对象;不存在时返回 None。
|
||
|
||
Raises:
|
||
NotFoundError: message_id 非法时抛出。
|
||
ConflictError: 并发冲突时抛出。
|
||
DependencyError: 数据库故障时抛出。
|
||
"""
|
||
try:
|
||
message_id_int = int(message_id)
|
||
except ValueError as exc:
|
||
raise NotFoundError("message", message_id) from exc
|
||
try:
|
||
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 且 <= 200;offset >= 0
|
||
@post 返回 Message 元组,按 ``created_at`` 降序排列,跳过前 offset 条;
|
||
每条 Message 含 ``channel_type``
|
||
@failure DependencyError
|
||
@consistency 强一致性:返回当前持久化的消息快照
|
||
|
||
Args:
|
||
role: 角色(可选,非空时按角色过滤,如 ``"admin"``)。
|
||
channel_type: 渠道类型(可选,非空时按渠道过滤)。
|
||
start_time: 起始时间(可选,含边界)。
|
||
end_time: 截止时间(可选,含边界)。
|
||
channel_status: 渠道状态(可选,非空时按状态过滤)。
|
||
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
|