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