包含以下变更: 1. 重构微信WOC账户模型,移除固定DEFAULT_ACCOUNT 2. 新增路由绑定管理、目录搜索导出能力 3. 扩展消息查询与出站管理过滤条件 4. 新增SSE事件广播、敏感字段/配置作用域注册表 5. 新增审计日志与配对过期定时任务 6. 优化会话处理与参数校验逻辑 7. 修复sender_id校验与outbox序列化问题
4631 lines
206 KiB
Python
4631 lines
206 KiB
Python
"""ChannelPersistenceAdapter:实现 PersistencePort,依赖 Repositories 聚合。
|
||
|
||
- 复用 Repositories 聚合(不直接持有 pg_manager),ORM↔dataclass 转换集中在 mappers.py
|
||
- 错误翻译:IntegrityError → ConflictError,其余 SQLAlchemyError → DependencyError
|
||
- fail-closed:审计日志与 DM 配对故障中止业务,不降级
|
||
- 事务边界:写操作接受可选 ``tx`` 参数(TransactionContext),
|
||
``tx`` 非空时加入应用层事务,**不得** 自主提交;``tx`` 为 ``None`` 时
|
||
按单方法提交(向后兼容)。
|
||
|
||
依赖边界:只依赖 yuxi.channels.contract(端口 + DTO + 错误)、
|
||
yuxi.repositories.channels(Repositories 聚合)、
|
||
yuxi.storage.postgres(ORM Model,用于 Outbox 关联会话查询)、
|
||
yuxi.utils.datetime_utils、sqlalchemy。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import asyncio
|
||
import uuid
|
||
from datetime import datetime, timedelta
|
||
from typing import TYPE_CHECKING, Any
|
||
|
||
from sqlalchemy import String, case, delete, extract, func, select, text
|
||
from sqlalchemy.exc import IntegrityError, SQLAlchemyError
|
||
from sqlalchemy.ext.asyncio import AsyncSession
|
||
from sqlalchemy.sql import Select
|
||
|
||
from yuxi.channels.contract.dtos.analytics import (
|
||
AccountActivityStat,
|
||
AccountAnalyticsQuery,
|
||
DeliveryAnalytics,
|
||
DeliveryFunnel,
|
||
DeliveryLatencyDistribution,
|
||
MessageAnalytics,
|
||
MessageDistribution,
|
||
PeerActivityStat,
|
||
PeerAnalyticsQuery,
|
||
SessionAnalytics,
|
||
TimeSeriesPoint,
|
||
)
|
||
from yuxi.channels.contract.dtos.audit import (
|
||
AuditEntry,
|
||
AuditLogId,
|
||
AuditLogStats,
|
||
AuditQuery,
|
||
)
|
||
from yuxi.channels.contract.dtos.channel import (
|
||
AccountFilter,
|
||
ChannelAccount,
|
||
ChannelSession,
|
||
ChannelType,
|
||
SessionFilter,
|
||
SessionStatus,
|
||
UserIdentity,
|
||
)
|
||
from yuxi.channels.contract.dtos.common import Operator, TrendDataPoint
|
||
from yuxi.channels.contract.dtos.dashboard import (
|
||
AccountStats,
|
||
ChannelDeliveryStat,
|
||
DashboardDeliveryQuery,
|
||
DashboardDeliveryResult,
|
||
SessionStats,
|
||
)
|
||
from yuxi.channels.contract.dtos.health import ConnectionPoolStatus
|
||
from yuxi.channels.contract.dtos.outbound import FormattedMessage
|
||
from yuxi.channels.contract.dtos.outbox import (
|
||
DeadLetterExportCmd,
|
||
OutboxConfig,
|
||
OutboxEntry,
|
||
OutboxId,
|
||
OutboxQueryFilter,
|
||
OutboxStats,
|
||
OutboxTrendQuery,
|
||
RetryContext,
|
||
)
|
||
from yuxi.channels.contract.dtos.pairing import (
|
||
PairingQuery,
|
||
PairingRecord,
|
||
PairingStatsQuery,
|
||
PairingStatsResult,
|
||
PairingStatus,
|
||
PairingTrendPoint,
|
||
)
|
||
from yuxi.channels.contract.dtos.persistence import (
|
||
CreatePairingCmd,
|
||
IdempotencyRecord,
|
||
SaveAuditLogCmd,
|
||
SaveChannelAccountCmd,
|
||
SaveChannelSessionCmd,
|
||
SaveOutboxEntryCmd,
|
||
SaveUserIdentityCmd,
|
||
UpdateChannelAccountCmd,
|
||
UpdateChannelSessionCmd,
|
||
)
|
||
from yuxi.channels.contract.dtos.route import (
|
||
RouteBindingFilter,
|
||
RouteBindingRule,
|
||
SaveRouteBindingCmd,
|
||
UpdateRouteBindingCmd,
|
||
)
|
||
from yuxi.channels.contract.errors import (
|
||
ConflictError,
|
||
DependencyError,
|
||
NotFoundError,
|
||
ValidationError,
|
||
)
|
||
from yuxi.channels.contract.errors.base import Error
|
||
from yuxi.channels.contract.errors.server import OperationTimeoutError
|
||
from yuxi.channels.contract.ports.driven.audit_log_repository_port import (
|
||
AuditLogRepositoryPort,
|
||
)
|
||
from yuxi.channels.contract.ports.driven.channel_account_repository_port import (
|
||
ChannelAccountRepositoryPort,
|
||
)
|
||
from yuxi.channels.contract.ports.driven.channel_session_repository_port import (
|
||
ChannelSessionRepositoryPort,
|
||
)
|
||
from yuxi.channels.contract.ports.driven.idempotency_repository_port import (
|
||
IdempotencyRepositoryPort,
|
||
)
|
||
from yuxi.channels.contract.ports.driven.logger_port import LoggerPort
|
||
from yuxi.channels.contract.ports.driven.message_repository_port import (
|
||
MessageRepositoryPort,
|
||
)
|
||
from yuxi.channels.contract.ports.driven.outbox_repository_port import (
|
||
OutboxRepositoryPort,
|
||
)
|
||
from yuxi.channels.contract.ports.driven.pairing_repository_port import (
|
||
PairingRepositoryPort,
|
||
)
|
||
from yuxi.channels.contract.ports.driven.persistence_health_port import (
|
||
PersistenceHealthPort,
|
||
)
|
||
from yuxi.channels.contract.ports.driven.route_binding_repository_port import (
|
||
RouteBindingRepositoryPort,
|
||
)
|
||
from yuxi.channels.contract.ports.driven.user_identity_repository_port import (
|
||
UserIdentityRepositoryPort,
|
||
)
|
||
from yuxi.repositories.channels import Repositories, create_repositories
|
||
from yuxi.storage.postgres.models_business import (
|
||
Conversation as ConversationORM,
|
||
)
|
||
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 (
|
||
ChannelOutboxEntry as ChannelOutboxEntryORM,
|
||
)
|
||
from yuxi.storage.postgres.models_channels import (
|
||
ChannelPairing as ChannelPairingORM,
|
||
)
|
||
from yuxi.storage.postgres.models_channels import (
|
||
ChannelSession as ChannelSessionORM,
|
||
)
|
||
from yuxi.utils.datetime_utils import UTC, format_utc_datetime, utc_now_naive
|
||
|
||
if TYPE_CHECKING:
|
||
from yuxi.channels.contract.ports.driven.transaction_port import (
|
||
TransactionContext,
|
||
)
|
||
|
||
from .mappers import (
|
||
audit_log_for_write,
|
||
channel_account_for_write,
|
||
orm_to_audit_log,
|
||
orm_to_channel_account,
|
||
orm_to_channel_session,
|
||
orm_to_outbox_entry,
|
||
orm_to_pairing,
|
||
orm_to_route_binding,
|
||
orm_to_user_identity,
|
||
outbox_entry_for_write,
|
||
pairing_for_write,
|
||
user_identity_for_write,
|
||
)
|
||
|
||
__all__ = ["ChannelPersistenceAdapter"]
|
||
|
||
|
||
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
|
||
|
||
|
||
def _escape_like_pattern(value: str, escape_char: str = "\\") -> str:
|
||
"""转义 LIKE 模式中的通配符,避免用户输入的 ``%`` / ``_`` 扩大匹配范围。
|
||
|
||
默认使用反斜杠作为转义字符;SQLAlchemy ``like(..., escape="\\")`` 会
|
||
指示数据库按标准方式解析转义序列。
|
||
"""
|
||
escaped = value.replace(escape_char, escape_char + escape_char)
|
||
escaped = escaped.replace("%", escape_char + "%")
|
||
escaped = escaped.replace("_", escape_char + "_")
|
||
return escaped
|
||
|
||
|
||
class ChannelPersistenceAdapter(
|
||
ChannelAccountRepositoryPort,
|
||
ChannelSessionRepositoryPort,
|
||
PairingRepositoryPort,
|
||
AuditLogRepositoryPort,
|
||
OutboxRepositoryPort,
|
||
UserIdentityRepositoryPort,
|
||
IdempotencyRepositoryPort,
|
||
MessageRepositoryPort,
|
||
PersistenceHealthPort,
|
||
RouteBindingRepositoryPort,
|
||
):
|
||
"""持久化被驱动适配器实现。
|
||
|
||
依赖 ``Repositories`` 聚合,覆盖渠道账户、渠道会话、配对审批、审计日志、
|
||
Outbox、用户身份 6 类 CRUD。所有方法均为 async,入参/出参均为契约层
|
||
不可变值对象。ORM↔dataclass 转换委托 ``mappers.py``,错误翻译遵循
|
||
``IntegrityError → ConflictError``、其余 ``SQLAlchemyError → DependencyError``。
|
||
|
||
事务边界:
|
||
- 写操作接受可选 ``tx`` 参数(``TransactionContext``)。
|
||
- ``tx`` 非空时加入应用层事务,向 Repo 传 ``commit=False``,由应用层
|
||
统一提交。
|
||
- ``tx`` 为 ``None`` 时按单方法提交(向后兼容,``commit=True``)。
|
||
|
||
fail-closed 语义:审计日志写入失败与 DM 配对表故障必须中止业务操作,
|
||
异常翻译为 ``DependencyError`` 后向上抛出,不降级、不吞异常。
|
||
"""
|
||
|
||
def __init__(
|
||
self,
|
||
db: AsyncSession,
|
||
outbox_config: OutboxConfig,
|
||
logger: LoggerPort,
|
||
) -> None:
|
||
"""初始化适配器,注入共享数据库会话并创建 Repositories 聚合。
|
||
|
||
Args:
|
||
db: SQLAlchemy 异步会话,所有仓储共享以保证事务一致性。
|
||
outbox_config: 发件箱配置,提供 ``ttl_seconds`` / ``max_retry``
|
||
等参数,替代直接读取全局 ``app_config``(§6.1 应用服务层
|
||
禁止依赖具体技术适配器)。
|
||
logger: 日志被驱动端口,用于记录 ``ping`` 等 **降级返回** 路径
|
||
上的故障,以及所有 ``except`` 兜底块中的异常信息。
|
||
"""
|
||
self._db = db
|
||
self._outbox_config = outbox_config
|
||
self._repos: Repositories = create_repositories(db)
|
||
self._logger: LoggerPort = logger
|
||
|
||
async def aclose(self) -> None:
|
||
"""关闭内部持有的 AsyncSession。
|
||
|
||
``ChannelPersistenceAdapter``、``ConversationAdapter`` 与
|
||
``SqlAlchemyTransactionAdapter`` 在 factory 中共用同一 ``AsyncSession``,
|
||
由本适配器统一负责关闭,避免重复关闭。关闭后该会话不可再用,
|
||
调用方应在插件卸载 / 宿主关停时通过 ``DrivenAdapters.close()`` 触发。
|
||
|
||
异常向上传播,由调用方负责捕获记录(符合契约层不吞异常的职责划分)。
|
||
|
||
Raises:
|
||
SQLAlchemyError: 底层会话关闭时的数据库故障。
|
||
"""
|
||
await self._db.close()
|
||
|
||
async def releaseSession(self) -> None:
|
||
"""释放当前会话占用的连接(``PersistenceHealthPort.releaseSession``)。
|
||
|
||
对共享 ``AsyncSession`` 执行 ``commit`` 以结束 SQLAlchemy 2.0 autobegin
|
||
开启的隐式事务,连接随之归还连接池。无活动事务时为 no-op(``commit``
|
||
对干净 session 安全)。后台扫描器在每轮扫描结束时调用本方法,避免
|
||
应用级共享会话因只读查询残留隐式事务而长期占用连接导致连接池耗尽。
|
||
|
||
不关闭会话本身(``aclose`` 负责最终关闭),扫描器下一轮仍可复用。
|
||
|
||
Raises:
|
||
DependencyError: 提交事务时的数据库故障(翻译后向上传播)。
|
||
"""
|
||
try:
|
||
await self._db.commit()
|
||
except SQLAlchemyError as exc:
|
||
raise self._translate_db_error(exc, "persistence_session") from exc
|
||
|
||
def _translate_db_error(self, exc: Exception, resource: str) -> Error:
|
||
"""将数据库异常翻译为契约层错误。
|
||
|
||
``IntegrityError`` 映射为 ``ConflictError``(并发冲突 / 唯一约束冲突),
|
||
其余 ``SQLAlchemyError`` 映射为 ``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
|
||
|
||
# ═══════════════════════════════════════════════════════════════════════════
|
||
# 渠道账户 CRUD
|
||
# ═══════════════════════════════════════════════════════════════════════════
|
||
|
||
async def saveChannelAccount(
|
||
self,
|
||
cmd: SaveChannelAccountCmd,
|
||
tx: TransactionContext | None = None,
|
||
) -> ChannelAccount:
|
||
"""保存渠道账户,返回含时间戳的 ChannelAccount。
|
||
|
||
``config`` 字段经 ``channel_account_for_write`` 加密后写入。
|
||
ID 重复时 ``IntegrityError`` 翻译为 ``ConflictError``。
|
||
|
||
Args:
|
||
cmd: 保存渠道账户命令。
|
||
tx: 事务上下文,非空时加入应用层事务,**不得** 自主提交。
|
||
|
||
Raises:
|
||
ConflictError: 账户 ID 重复(唯一约束冲突)。
|
||
DependencyError: 数据库故障。
|
||
"""
|
||
commit = self._should_commit(tx)
|
||
data: dict[str, Any] = {
|
||
"channel_type": cmd.channel_type,
|
||
"account_id": cmd.account_id,
|
||
"display_name": cmd.display_name,
|
||
"config": cmd.config,
|
||
"enabled": cmd.enabled,
|
||
}
|
||
if cmd.status is not None:
|
||
data["status"] = cmd.status.value
|
||
if cmd.service_user_uid is not None:
|
||
data["service_user_uid"] = cmd.service_user_uid
|
||
data = channel_account_for_write(data)
|
||
try:
|
||
orm = await self._repos.account.create(data, commit=commit)
|
||
return orm_to_channel_account(orm)
|
||
except IntegrityError as exc:
|
||
raise self._translate_db_error(exc, "channel_account") from exc
|
||
except SQLAlchemyError as exc:
|
||
raise self._translate_db_error(exc, "channel_account") from exc
|
||
except (NotFoundError, ConflictError, DependencyError):
|
||
# 契约层错误:mappers 翻译的 DependencyError 与本适配器抛出的 NotFoundError/ConflictError
|
||
# 直接放行,不二次翻译,保留原始异常链与错误码
|
||
raise
|
||
except Exception as exc:
|
||
# 兜底:mappers 调用过程中可能抛出 TypeError 等非契约异常,翻译为 DependencyError
|
||
# 防止穿透至核心层(INV-7);保留异常链以便追踪
|
||
await self._logger.error(
|
||
"channel_account_save_failed",
|
||
resource="channel_account",
|
||
error=str(exc),
|
||
)
|
||
raise DependencyError("channel_account", Error(str(exc))) from exc
|
||
|
||
async def updateChannelAccount(
|
||
self,
|
||
cmd: UpdateChannelAccountCmd,
|
||
tx: TransactionContext | None = None,
|
||
) -> ChannelAccount:
|
||
"""更新已存在账户的指定字段,返回更新后的 ChannelAccount。
|
||
|
||
仅更新 cmd 中非 None 的字段。``config`` 字段经加密后写入。
|
||
账户不存在时抛 ``NotFoundError``。
|
||
|
||
Args:
|
||
cmd: 更新渠道账户命令。
|
||
tx: 事务上下文,非空时加入应用层事务,**不得** 自主提交。
|
||
|
||
Raises:
|
||
NotFoundError: 账户不存在。
|
||
ConflictError: 并发冲突。
|
||
DependencyError: 数据库故障。
|
||
"""
|
||
commit = self._should_commit(tx)
|
||
try:
|
||
orm = await self._repos.account.get_by_type_and_account(cmd.channel_type, cmd.account_id)
|
||
if orm is None:
|
||
raise NotFoundError("channel_account", cmd.account_id)
|
||
data: dict = {}
|
||
if cmd.display_name is not None:
|
||
data["display_name"] = cmd.display_name
|
||
if cmd.config is not None:
|
||
data["config"] = cmd.config
|
||
if cmd.enabled is not None:
|
||
data["enabled"] = cmd.enabled
|
||
if cmd.status is not None:
|
||
data["status"] = cmd.status.value
|
||
if cmd.transport_cursor is not None:
|
||
data["transport_cursor"] = cmd.transport_cursor
|
||
if cmd.last_rotated_at is not None:
|
||
data["last_rotated_at"] = cmd.last_rotated_at
|
||
if cmd.onboarding_status is not None:
|
||
data["onboarding_status"] = cmd.onboarding_status
|
||
if cmd.credential_ref is not None:
|
||
data["credential_ref"] = cmd.credential_ref
|
||
if cmd.credential_version is not None:
|
||
data["credential_version"] = cmd.credential_version
|
||
if cmd.last_error is not None:
|
||
data["last_error"] = cmd.last_error
|
||
data = channel_account_for_write(data)
|
||
updated = await self._repos.account.update(orm, data, commit=commit)
|
||
return orm_to_channel_account(updated)
|
||
except IntegrityError as exc:
|
||
raise self._translate_db_error(exc, "channel_account") from exc
|
||
except SQLAlchemyError as exc:
|
||
raise self._translate_db_error(exc, "channel_account") from exc
|
||
except (NotFoundError, ConflictError, DependencyError):
|
||
# 契约层错误:mappers 翻译的 DependencyError 与本适配器抛出的 NotFoundError/ConflictError
|
||
# 直接放行,不二次翻译,保留原始异常链与错误码
|
||
raise
|
||
except Exception as exc:
|
||
# 兜底:mappers 调用过程中可能抛出 TypeError 等非契约异常,翻译为 DependencyError
|
||
# 防止穿透至核心层(INV-7);保留异常链以便追踪
|
||
await self._logger.error(
|
||
"channel_account_update_failed",
|
||
resource="channel_account",
|
||
error=str(exc),
|
||
)
|
||
raise DependencyError("channel_account", Error(str(exc))) from exc
|
||
|
||
async def getChannelAccount(self, channel_type: ChannelType, account_id: str) -> ChannelAccount | None:
|
||
"""按类型与 ID 查询渠道账户,返回解密后的配置;不存在返回 None。
|
||
|
||
Raises:
|
||
DependencyError: 数据库故障。
|
||
"""
|
||
try:
|
||
orm = await self._repos.account.get_by_type_and_account(channel_type, account_id)
|
||
if orm is None:
|
||
return None
|
||
return orm_to_channel_account(orm)
|
||
except IntegrityError as exc:
|
||
raise self._translate_db_error(exc, "channel_account") from exc
|
||
except SQLAlchemyError as exc:
|
||
raise self._translate_db_error(exc, "channel_account") from exc
|
||
except (NotFoundError, ConflictError, DependencyError):
|
||
# 契约层错误:mappers 翻译的 DependencyError 与本适配器抛出的 NotFoundError/ConflictError
|
||
# 直接放行,不二次翻译,保留原始异常链与错误码
|
||
raise
|
||
except Exception as exc:
|
||
# 兜底:mappers 调用过程中可能抛出 TypeError 等非契约异常,翻译为 DependencyError
|
||
# 防止穿透至核心层(INV-7);保留异常链以便追踪
|
||
await self._logger.error(
|
||
"channel_account_get_failed",
|
||
resource="channel_account",
|
||
error=str(exc),
|
||
)
|
||
raise DependencyError("channel_account", Error(str(exc))) from exc
|
||
|
||
async def listChannelAccounts(
|
||
self,
|
||
channel_type: ChannelType | None = None,
|
||
limit: int = 1000,
|
||
offset: int = 0,
|
||
) -> tuple[ChannelAccount, ...]:
|
||
"""列出渠道账户,channel_type 提供时仅返回该类型,否则返回全部。
|
||
|
||
Raises:
|
||
DependencyError: 数据库故障。
|
||
"""
|
||
try:
|
||
orms = await self._repos.account.list(
|
||
channel_type=channel_type if channel_type else None,
|
||
limit=limit,
|
||
offset=offset,
|
||
)
|
||
return tuple(orm_to_channel_account(orm) for orm in orms)
|
||
except IntegrityError as exc:
|
||
raise self._translate_db_error(exc, "channel_account") from exc
|
||
except SQLAlchemyError as exc:
|
||
raise self._translate_db_error(exc, "channel_account") from exc
|
||
except (NotFoundError, ConflictError, DependencyError):
|
||
# 契约层错误:mappers 翻译的 DependencyError 与本适配器抛出的 NotFoundError/ConflictError
|
||
# 直接放行,不二次翻译,保留原始异常链与错误码
|
||
raise
|
||
except Exception as exc:
|
||
# 兜底:mappers 调用过程中可能抛出 TypeError 等非契约异常,翻译为 DependencyError
|
||
# 防止穿透至核心层(INV-7);保留异常链以便追踪
|
||
await self._logger.error(
|
||
"channel_account_list_failed",
|
||
resource="channel_account",
|
||
error=str(exc),
|
||
)
|
||
raise DependencyError("channel_account", Error(str(exc))) from exc
|
||
|
||
async def findAccountsByFilter(
|
||
self,
|
||
filter: AccountFilter,
|
||
) -> tuple[ChannelAccount, ...]:
|
||
"""按筛选条件查询渠道账户(ACC-BATCH-STATE)。
|
||
|
||
支持按 ``channel_type`` / ``status`` 等条件批量筛选账户,供批量启停
|
||
操作先获取待操作账户列表。与 ``listChannelAccounts`` 的区别:后者仅
|
||
支持 ``channel_type`` 过滤,本方法支持复合筛选条件(含状态过滤)。
|
||
仅含未软删除账户(``is_deleted=0``),配置已脱敏。
|
||
|
||
Raises:
|
||
DependencyError: 数据库故障。
|
||
"""
|
||
try:
|
||
stmt = select(ChannelAccountORM).where(ChannelAccountORM.is_deleted == 0)
|
||
if filter.channel_type is not None:
|
||
stmt = stmt.where(ChannelAccountORM.channel_type == filter.channel_type)
|
||
if filter.status is not None:
|
||
stmt = stmt.where(ChannelAccountORM.status == filter.status.value)
|
||
result = await self._db.execute(stmt)
|
||
orms = result.scalars().all()
|
||
return tuple(orm_to_channel_account(orm) for orm in orms)
|
||
except IntegrityError as exc:
|
||
raise self._translate_db_error(exc, "channel_account") from exc
|
||
except SQLAlchemyError as exc:
|
||
raise self._translate_db_error(exc, "channel_account") from exc
|
||
except (NotFoundError, ConflictError, DependencyError):
|
||
# 契约层错误:mappers 翻译的 DependencyError 与本适配器抛出的 NotFoundError/ConflictError
|
||
# 直接放行,不二次翻译,保留原始异常链与错误码
|
||
raise
|
||
except Exception as exc:
|
||
# 兜底:mappers 调用过程中可能抛出 TypeError 等非契约异常,翻译为 DependencyError
|
||
# 防止穿透至核心层(INV-7);保留异常链以便追踪
|
||
await self._logger.error(
|
||
"channel_account_find_accounts_by_filter_failed",
|
||
resource="channel_account",
|
||
error=str(exc),
|
||
)
|
||
raise DependencyError("channel_account", Error(str(exc))) from exc
|
||
|
||
async def deleteChannelAccount(
|
||
self,
|
||
channel_type: ChannelType,
|
||
account_id: str,
|
||
tx: TransactionContext | None = None,
|
||
) -> bool:
|
||
"""软删除渠道账户,存在并删除返回 True,不存在返回 False;不级联删除会话。
|
||
|
||
Args:
|
||
channel_type: 渠道类型。
|
||
account_id: 渠道账户 ID。
|
||
tx: 事务上下文,非空时加入应用层事务,**不得** 自主提交。
|
||
|
||
Raises:
|
||
DependencyError: 数据库故障。
|
||
"""
|
||
commit = self._should_commit(tx)
|
||
try:
|
||
orm = await self._repos.account.get_by_type_and_account(channel_type, account_id)
|
||
if orm is None:
|
||
return False
|
||
await self._repos.account.delete_by_id(orm.id, commit=commit)
|
||
return True
|
||
except IntegrityError as exc:
|
||
raise self._translate_db_error(exc, "channel_account") from exc
|
||
except SQLAlchemyError as exc:
|
||
raise self._translate_db_error(exc, "channel_account") from exc
|
||
|
||
# ═══════════════════════════════════════════════════════════════════════════
|
||
# 渠道会话 CRUD
|
||
# ═══════════════════════════════════════════════════════════════════════════
|
||
|
||
async def saveChannelSession(
|
||
self,
|
||
cmd: SaveChannelSessionCmd,
|
||
tx: TransactionContext | None = None,
|
||
) -> ChannelSession:
|
||
"""保存渠道会话,返回含 session_id 与时间戳的 ChannelSession。
|
||
|
||
需先查询渠道账户获取 int 主键(``account_id`` 为 str 业务 ID)。
|
||
唯一约束冲突抛 ``ConflictError``。
|
||
|
||
Args:
|
||
cmd: 保存渠道会话命令。
|
||
tx: 事务上下文,非空时加入应用层事务,**不得** 自主提交。
|
||
|
||
Raises:
|
||
NotFoundError: 渠道账户不存在。
|
||
ConflictError: 唯一约束冲突。
|
||
DependencyError: 数据库故障。
|
||
"""
|
||
commit = self._should_commit(tx)
|
||
try:
|
||
account_orm = await self._repos.account.get_by_type_and_account(cmd.channel_type, cmd.account_id)
|
||
if account_orm is None:
|
||
raise NotFoundError("channel_account", cmd.account_id)
|
||
conversation_id_int: int | None = None
|
||
if cmd.conversation_id:
|
||
try:
|
||
conversation_id_int = int(cmd.conversation_id)
|
||
except (TypeError, ValueError) as exc:
|
||
raise NotFoundError("conversation", cmd.conversation_id) from exc
|
||
data = {
|
||
"session_id": uuid.uuid4().hex,
|
||
"account_id": account_orm.id,
|
||
"channel_type": cmd.channel_type,
|
||
"peer_id": cmd.peer_id,
|
||
"chat_type": cmd.chat_type,
|
||
"conversation_id": conversation_id_int,
|
||
"unified_identity_id": cmd.unified_identity_id,
|
||
"owner_peer_id": cmd.owner_peer_id,
|
||
"is_temporary": cmd.is_temporary,
|
||
}
|
||
orm = await self._repos.session.create(data, commit=commit)
|
||
return orm_to_channel_session(orm)
|
||
except IntegrityError as exc:
|
||
raise self._translate_db_error(exc, "channel_session") from exc
|
||
except SQLAlchemyError as exc:
|
||
raise self._translate_db_error(exc, "channel_session") from exc
|
||
except (NotFoundError, ConflictError, DependencyError):
|
||
# 契约层错误:mappers 翻译的 DependencyError 与本适配器抛出的 NotFoundError/ConflictError
|
||
# 直接放行,不二次翻译,保留原始异常链与错误码
|
||
raise
|
||
except Exception as exc:
|
||
# 兜底:mappers 调用过程中可能抛出 TypeError 等非契约异常,翻译为 DependencyError
|
||
# 防止穿透至核心层(INV-7);保留异常链以便追踪
|
||
await self._logger.error(
|
||
"channel_session_save_failed",
|
||
resource="channel_session",
|
||
error=str(exc),
|
||
)
|
||
raise DependencyError("channel_session", Error(str(exc))) from exc
|
||
|
||
async def updateChannelSession(
|
||
self,
|
||
cmd: UpdateChannelSessionCmd,
|
||
tx: TransactionContext | None = None,
|
||
) -> ChannelSession:
|
||
"""更新已存在会话的指定字段,返回更新后的 ChannelSession。
|
||
|
||
仅更新 cmd 中非 None 的字段。会话不存在时抛 ``NotFoundError``。
|
||
|
||
Args:
|
||
cmd: 更新渠道会话命令。
|
||
tx: 事务上下文,非空时加入应用层事务,**不得** 自主提交。
|
||
|
||
Raises:
|
||
NotFoundError: 会话不存在。
|
||
ConflictError: 并发冲突。
|
||
DependencyError: 数据库故障。
|
||
"""
|
||
commit = self._should_commit(tx)
|
||
try:
|
||
orm = await self._repos.session.get_by_session_id(cmd.session_id)
|
||
if orm is None:
|
||
raise NotFoundError("channel_session", cmd.session_id)
|
||
data: dict = {}
|
||
if cmd.conversation_id is not None:
|
||
try:
|
||
data["conversation_id"] = int(cmd.conversation_id)
|
||
except (TypeError, ValueError) as exc:
|
||
raise NotFoundError("conversation", cmd.conversation_id) from exc
|
||
if cmd.unified_identity_id is not None:
|
||
data["unified_identity_id"] = cmd.unified_identity_id
|
||
if cmd.owner_peer_id is not None:
|
||
data["owner_peer_id"] = cmd.owner_peer_id
|
||
if cmd.is_temporary is not None:
|
||
data["is_temporary"] = cmd.is_temporary
|
||
if cmd.closed_at is not None:
|
||
data["closed_at"] = cmd.closed_at
|
||
updated = await self._repos.session.update(orm, data, commit=commit)
|
||
return orm_to_channel_session(updated)
|
||
except IntegrityError as exc:
|
||
raise self._translate_db_error(exc, "channel_session") from exc
|
||
except SQLAlchemyError as exc:
|
||
raise self._translate_db_error(exc, "channel_session") from exc
|
||
except (NotFoundError, ConflictError, DependencyError):
|
||
# 契约层错误:mappers 翻译的 DependencyError 与本适配器抛出的 NotFoundError/ConflictError
|
||
# 直接放行,不二次翻译,保留原始异常链与错误码
|
||
raise
|
||
except Exception as exc:
|
||
# 兜底:mappers 调用过程中可能抛出 TypeError 等非契约异常,翻译为 DependencyError
|
||
# 防止穿透至核心层(INV-7);保留异常链以便追踪
|
||
await self._logger.error(
|
||
"channel_session_update_failed",
|
||
resource="channel_session",
|
||
error=str(exc),
|
||
)
|
||
raise DependencyError("channel_session", Error(str(exc))) from exc
|
||
|
||
async def getChannelSession(self, session_id: str) -> ChannelSession | None:
|
||
"""按 session_id 查询未软删除的渠道会话;不存在返回 None。
|
||
|
||
Raises:
|
||
DependencyError: 数据库故障。
|
||
"""
|
||
try:
|
||
orm = await self._repos.session.get_by_session_id(session_id)
|
||
if orm is None:
|
||
return None
|
||
return orm_to_channel_session(orm)
|
||
except IntegrityError as exc:
|
||
raise self._translate_db_error(exc, "channel_session") from exc
|
||
except SQLAlchemyError as exc:
|
||
raise self._translate_db_error(exc, "channel_session") from exc
|
||
except (NotFoundError, ConflictError, DependencyError):
|
||
# 契约层错误:mappers 翻译的 DependencyError 与本适配器抛出的 NotFoundError/ConflictError
|
||
# 直接放行,不二次翻译,保留原始异常链与错误码
|
||
raise
|
||
except Exception as exc:
|
||
# 兜底:mappers 调用过程中可能抛出 TypeError 等非契约异常,翻译为 DependencyError
|
||
# 防止穿透至核心层(INV-7);保留异常链以便追踪
|
||
await self._logger.error(
|
||
"channel_session_get_failed",
|
||
resource="channel_session",
|
||
error=str(exc),
|
||
)
|
||
raise DependencyError("channel_session", Error(str(exc))) from exc
|
||
|
||
async def getChannelSessionByPeer(
|
||
self,
|
||
channel_type: ChannelType,
|
||
account_id: str,
|
||
peer_id: str,
|
||
) -> ChannelSession | None:
|
||
"""按渠道账户与对端 ID 查询未软删除的渠道会话;不存在返回 None。
|
||
|
||
先查 account ORM 获取主键,再按 (account_id, peer_id) 查会话。
|
||
|
||
Raises:
|
||
DependencyError: 数据库故障。
|
||
"""
|
||
try:
|
||
account_orm = await self._repos.account.get_by_type_and_account(channel_type, account_id)
|
||
if account_orm is None:
|
||
return None
|
||
orm = await self._repos.session.get_by_account_and_peer(account_orm.id, peer_id)
|
||
if orm is None:
|
||
return None
|
||
return orm_to_channel_session(orm)
|
||
except IntegrityError as exc:
|
||
raise self._translate_db_error(exc, "channel_session") from exc
|
||
except SQLAlchemyError as exc:
|
||
raise self._translate_db_error(exc, "channel_session") from exc
|
||
except (NotFoundError, ConflictError, DependencyError):
|
||
# 契约层错误:mappers 翻译的 DependencyError 与本适配器抛出的 NotFoundError/ConflictError
|
||
# 直接放行,不二次翻译,保留原始异常链与错误码
|
||
raise
|
||
except Exception as exc:
|
||
# 兜底:mappers 调用过程中可能抛出 TypeError 等非契约异常,翻译为 DependencyError
|
||
# 防止穿透至核心层(INV-7);保留异常链以便追踪
|
||
await self._logger.error(
|
||
"channel_session_get_by_peer_failed",
|
||
resource="channel_session",
|
||
error=str(exc),
|
||
)
|
||
raise DependencyError("channel_session", Error(str(exc))) from exc
|
||
|
||
async def getChannelSessionByConversationId(
|
||
self,
|
||
conversation_id: str,
|
||
) -> ChannelSession | None:
|
||
"""按内部会话 ID 查询未软删除的渠道会话;不存在返回 None。
|
||
|
||
``conversation_id`` 为业务字符串,ORM 中为 Integer 外键,
|
||
非数字时视为会话不存在返回 None。
|
||
|
||
Raises:
|
||
DependencyError: 数据库故障。
|
||
"""
|
||
try:
|
||
try:
|
||
conversation_id_int = int(conversation_id)
|
||
except (TypeError, ValueError):
|
||
return None
|
||
stmt = (
|
||
select(ChannelSessionORM)
|
||
.where(ChannelSessionORM.is_deleted == 0)
|
||
.where(ChannelSessionORM.conversation_id == conversation_id_int)
|
||
.limit(1)
|
||
)
|
||
result = await self._db.execute(stmt)
|
||
orm = result.scalars().first()
|
||
if orm is None:
|
||
return None
|
||
return orm_to_channel_session(orm)
|
||
except IntegrityError as exc:
|
||
raise self._translate_db_error(exc, "channel_session") from exc
|
||
except SQLAlchemyError as exc:
|
||
raise self._translate_db_error(exc, "channel_session") from exc
|
||
except (NotFoundError, ConflictError, DependencyError):
|
||
# 契约层错误:mappers 翻译的 DependencyError 与本适配器抛出的 NotFoundError/ConflictError
|
||
# 直接放行,不二次翻译,保留原始异常链与错误码
|
||
raise
|
||
except Exception as exc:
|
||
# 兜底:mappers 调用过程中可能抛出 TypeError 等非契约异常,翻译为 DependencyError
|
||
# 防止穿透至核心层(INV-7);保留异常链以便追踪
|
||
await self._logger.error(
|
||
"channel_session_get_by_conversation_id_failed",
|
||
resource="channel_session",
|
||
error=str(exc),
|
||
)
|
||
raise DependencyError("channel_session", Error(str(exc))) from exc
|
||
|
||
async def listChannelSessionsByConversationId(
|
||
self,
|
||
conversation_id: str,
|
||
) -> tuple[ChannelSession, ...]:
|
||
"""按内部会话 ID 列出全部关联的渠道会话(含已软删除)。
|
||
|
||
用于 ``SessionMerger`` 在合并前加载源会话的全部 ChannelSession
|
||
重建聚合根并调用 ``markMerged()`` 校验业务规则(FR-26 主会话所有者
|
||
保护)。与 ``getChannelSessionByConversationId`` 的区别:本方法返回
|
||
全部(含已软删除)会话,供领域服务做完整状态校验。
|
||
|
||
Raises:
|
||
DependencyError: 数据库故障。
|
||
"""
|
||
try:
|
||
try:
|
||
conversation_id_int = int(conversation_id)
|
||
except (TypeError, ValueError):
|
||
return ()
|
||
stmt = select(ChannelSessionORM).where(ChannelSessionORM.conversation_id == conversation_id_int)
|
||
result = await self._db.execute(stmt)
|
||
orms = result.scalars().all()
|
||
return tuple(orm_to_channel_session(orm) for orm in orms)
|
||
except IntegrityError as exc:
|
||
raise self._translate_db_error(exc, "channel_session") from exc
|
||
except SQLAlchemyError as exc:
|
||
raise self._translate_db_error(exc, "channel_session") from exc
|
||
except (NotFoundError, ConflictError, DependencyError):
|
||
raise
|
||
except Exception as exc:
|
||
await self._logger.error(
|
||
"channel_session_list_by_conversation_id_failed",
|
||
resource="channel_session",
|
||
error=str(exc),
|
||
)
|
||
raise DependencyError("channel_session", Error(str(exc))) from exc
|
||
|
||
async def listChannelSessions(
|
||
self,
|
||
channel_type: ChannelType | None = None,
|
||
limit: int = 1000,
|
||
offset: int = 0,
|
||
peer_id: str | None = None,
|
||
created_after: datetime | None = None,
|
||
created_before: datetime | None = None,
|
||
owner_peer_id: str | None = None,
|
||
status: SessionStatus | None = None,
|
||
last_message_after: datetime | None = None,
|
||
last_message_before: datetime | None = None,
|
||
abnormal: bool = False,
|
||
) -> tuple[ChannelSession, ...]:
|
||
"""列出渠道会话(广播),channel_type 提供时仅返回该类型。
|
||
|
||
``peer_id`` 提供时执行模糊匹配,供运维排查按对端标识检索会话。
|
||
``created_after`` / ``created_before`` 提供时按会话创建时间范围过滤。
|
||
``owner_peer_id`` / ``status`` / ``last_message_*`` / ``abnormal``
|
||
透传至 repository 层按对应条件过滤。
|
||
|
||
Raises:
|
||
DependencyError: 数据库故障。
|
||
"""
|
||
try:
|
||
orms = await self._repos.session.list(
|
||
channel_type=channel_type if channel_type else None,
|
||
peer_id=peer_id,
|
||
start_time=created_after,
|
||
end_time=created_before,
|
||
owner_peer_id=owner_peer_id,
|
||
status=status.value if status else None,
|
||
last_message_after=last_message_after,
|
||
last_message_before=last_message_before,
|
||
abnormal=abnormal,
|
||
limit=limit,
|
||
offset=offset,
|
||
)
|
||
return tuple(orm_to_channel_session(orm) for orm in orms)
|
||
except IntegrityError as exc:
|
||
raise self._translate_db_error(exc, "channel_session") from exc
|
||
except SQLAlchemyError as exc:
|
||
raise self._translate_db_error(exc, "channel_session") from exc
|
||
except (NotFoundError, ConflictError, DependencyError):
|
||
# 契约层错误:mappers 翻译的 DependencyError 与本适配器抛出的 NotFoundError/ConflictError
|
||
# 直接放行,不二次翻译,保留原始异常链与错误码
|
||
raise
|
||
except Exception as exc:
|
||
# 兜底:mappers 调用过程中可能抛出 TypeError 等非契约异常,翻译为 DependencyError
|
||
# 防止穿透至核心层(INV-7);保留异常链以便追踪
|
||
await self._logger.error(
|
||
"channel_session_list_failed",
|
||
resource="channel_session",
|
||
error=str(exc),
|
||
)
|
||
raise DependencyError("channel_session", Error(str(exc))) from exc
|
||
|
||
async def countChannelSessions(
|
||
self,
|
||
channel_type: ChannelType | None = None,
|
||
peer_id: str | None = None,
|
||
created_after: datetime | None = None,
|
||
created_before: datetime | None = None,
|
||
owner_peer_id: str | None = None,
|
||
status: SessionStatus | None = None,
|
||
last_message_after: datetime | None = None,
|
||
last_message_before: datetime | None = None,
|
||
abnormal: bool = False,
|
||
) -> int:
|
||
"""统计满足过滤条件的未软删除会话数。
|
||
|
||
过滤条件与 ``listChannelSessions`` 对齐,供分页 total 字段使用。
|
||
|
||
Raises:
|
||
DependencyError: 数据库故障。
|
||
"""
|
||
try:
|
||
return await self._repos.session.count(
|
||
channel_type=channel_type if channel_type else None,
|
||
peer_id=peer_id,
|
||
start_time=created_after,
|
||
end_time=created_before,
|
||
owner_peer_id=owner_peer_id,
|
||
status=status.value if status else None,
|
||
last_message_after=last_message_after,
|
||
last_message_before=last_message_before,
|
||
abnormal=abnormal,
|
||
)
|
||
except IntegrityError as exc:
|
||
raise self._translate_db_error(exc, "channel_session") from exc
|
||
except SQLAlchemyError as exc:
|
||
raise self._translate_db_error(exc, "channel_session") from exc
|
||
except (NotFoundError, ConflictError, DependencyError):
|
||
raise
|
||
except Exception as exc:
|
||
await self._logger.error(
|
||
"channel_session_count_failed",
|
||
resource="channel_session",
|
||
error=str(exc),
|
||
)
|
||
raise DependencyError("channel_session", Error(str(exc))) from exc
|
||
|
||
async def findSessionsByFilter(
|
||
self,
|
||
filter: SessionFilter,
|
||
) -> tuple[ChannelSession, ...]:
|
||
"""按筛选条件查询渠道会话(SES-BATCH-CLOSE-01)。
|
||
|
||
支持按 ``channel_type`` / ``inactive_before`` / ``status`` /
|
||
``unified_identity_id`` 等条件批量筛选会话,供批量关闭操作先获取
|
||
待关闭会话列表,或供 P3 绑定用例查询同一统一身份下的兄弟会话。与
|
||
``listChannelSessions`` 的区别:后者仅支持 ``channel_type`` 过滤,
|
||
本方法支持复合筛选条件。仅含未软删除会话(``is_deleted=0``)。
|
||
|
||
``inactive_before`` 映射到 ``last_message_at``:``last_message_at``
|
||
为 NULL(从未收消息)或早于该时间均视为非活跃。
|
||
``status`` 映射到 ``closed_at``:``active`` → ``closed_at IS NULL``,
|
||
``closed`` → ``closed_at IS NOT NULL``(session 表无独立 status 列)。
|
||
``unified_identity_id`` 映射到 ``unified_identity_id`` 列精确匹配
|
||
(P3 渐进式绑定,跨渠道会话合并场景)。
|
||
|
||
Raises:
|
||
DependencyError: 数据库故障。
|
||
"""
|
||
try:
|
||
stmt = select(ChannelSessionORM).where(ChannelSessionORM.is_deleted == 0)
|
||
if filter.channel_type is not None:
|
||
stmt = stmt.where(ChannelSessionORM.channel_type == filter.channel_type)
|
||
if filter.unified_identity_id is not None:
|
||
stmt = stmt.where(ChannelSessionORM.unified_identity_id == filter.unified_identity_id)
|
||
if filter.inactive_before is not None:
|
||
inactive_before_dt = _coerceFilterDateTime(filter.inactive_before)
|
||
# last_message_at 为 NULL(从未收消息)或早于 inactive_before 均视为非活跃
|
||
stmt = stmt.where(
|
||
(ChannelSessionORM.last_message_at.is_(None))
|
||
| (ChannelSessionORM.last_message_at < inactive_before_dt)
|
||
)
|
||
if filter.status is not None:
|
||
if filter.status == SessionStatus.ACTIVE:
|
||
stmt = stmt.where(ChannelSessionORM.closed_at.is_(None))
|
||
else:
|
||
stmt = stmt.where(ChannelSessionORM.closed_at.isnot(None))
|
||
result = await self._db.execute(stmt)
|
||
orms = result.scalars().all()
|
||
return tuple(orm_to_channel_session(orm) for orm in orms)
|
||
except IntegrityError as exc:
|
||
raise self._translate_db_error(exc, "channel_session") from exc
|
||
except SQLAlchemyError as exc:
|
||
raise self._translate_db_error(exc, "channel_session") from exc
|
||
except (NotFoundError, ConflictError, DependencyError):
|
||
# 契约层错误:mappers 翻译的 DependencyError 与本适配器抛出的 NotFoundError/ConflictError
|
||
# 直接放行,不二次翻译,保留原始异常链与错误码
|
||
raise
|
||
except Exception as exc:
|
||
# 兜底:mappers 调用过程中可能抛出 TypeError 等非契约异常,翻译为 DependencyError
|
||
# 防止穿透至核心层(INV-7);保留异常链以便追踪
|
||
await self._logger.error(
|
||
"channel_session_find_sessions_by_filter_failed",
|
||
resource="channel_session",
|
||
error=str(exc),
|
||
)
|
||
raise DependencyError("channel_session", Error(str(exc))) from exc
|
||
|
||
async def touchChannelSessionLastMessageAt(
|
||
self,
|
||
session_id: str,
|
||
tx: TransactionContext | None = None,
|
||
) -> bool:
|
||
"""更新会话最近消息时间(高频轻量更新,不加载实体)。
|
||
|
||
会话存在且未软删除返回 True,否则返回 False。
|
||
|
||
Args:
|
||
session_id: 会话 ID。
|
||
tx: 事务上下文,非空时加入应用层事务,**不得** 自主提交。
|
||
|
||
Raises:
|
||
DependencyError: 数据库故障。
|
||
"""
|
||
commit = self._should_commit(tx)
|
||
try:
|
||
orm = await self._repos.session.get_by_session_id(session_id)
|
||
if orm is None:
|
||
return False
|
||
rowcount = await self._repos.session.update_last_message_at(orm.id, commit=commit)
|
||
return rowcount > 0
|
||
except IntegrityError as exc:
|
||
raise self._translate_db_error(exc, "channel_session") from exc
|
||
except SQLAlchemyError as exc:
|
||
raise self._translate_db_error(exc, "channel_session") from exc
|
||
|
||
async def listInactiveTemporarySessions(
|
||
self,
|
||
inactive_before: datetime,
|
||
limit: int,
|
||
) -> tuple[ChannelSession, ...]:
|
||
"""列出非活跃的临时会话(FR-27 inactive 临时会话清理)。
|
||
|
||
查询条件:``is_temporary == True`` AND ``is_deleted == 0`` AND
|
||
``closed_at IS NULL`` AND ``last_message_at < inactive_before``,
|
||
返回最多 ``limit`` 条待清理的临时会话。
|
||
|
||
Raises:
|
||
DependencyError: 数据库故障。
|
||
"""
|
||
try:
|
||
stmt = (
|
||
select(ChannelSessionORM)
|
||
.where(
|
||
ChannelSessionORM.is_temporary.is_(True),
|
||
ChannelSessionORM.is_deleted == 0,
|
||
ChannelSessionORM.closed_at.is_(None),
|
||
ChannelSessionORM.last_message_at < inactive_before,
|
||
)
|
||
.limit(limit)
|
||
)
|
||
result = await self._db.execute(stmt)
|
||
orms = result.scalars().all()
|
||
return tuple(orm_to_channel_session(orm) for orm in orms)
|
||
except IntegrityError as exc:
|
||
raise self._translate_db_error(exc, "channel_session") from exc
|
||
except SQLAlchemyError as exc:
|
||
raise self._translate_db_error(exc, "channel_session") from exc
|
||
except (NotFoundError, ConflictError, DependencyError):
|
||
# 契约层错误:mappers 翻译的 DependencyError 直接放行,不二次翻译,
|
||
# 保留原始异常链与错误码
|
||
raise
|
||
except Exception as exc:
|
||
# 兜底:mappers 调用过程中可能抛出 TypeError 等非契约异常,翻译为
|
||
# DependencyError 防止穿透至核心层(INV-7);保留异常链以便追踪
|
||
await self._logger.error(
|
||
"channel_session_list_inactive_temporary_sessions_failed",
|
||
resource="channel_session",
|
||
error=str(exc),
|
||
)
|
||
raise DependencyError("channel_session", Error(str(exc))) from exc
|
||
|
||
async def cleanupInactiveSessions(self, session_ids: list[str]) -> int:
|
||
"""批量关闭非活跃临时会话(FR-27 inactive 临时会话清理)。
|
||
|
||
对 ``session_ids`` 中未软删除且未关闭的会话执行状态机迁移
|
||
(active → closed):设置 ``closed_at`` / ``updated_at`` 为当前时间
|
||
并递增 ``version``,语义与聚合根 ``ChannelSession.close()`` 一致。
|
||
返回实际关闭的记录数。
|
||
|
||
Raises:
|
||
DependencyError: 数据库故障。
|
||
"""
|
||
if not session_ids:
|
||
return 0
|
||
try:
|
||
stmt = select(ChannelSessionORM).where(
|
||
ChannelSessionORM.session_id.in_(session_ids),
|
||
ChannelSessionORM.is_deleted == 0,
|
||
ChannelSessionORM.closed_at.is_(None),
|
||
)
|
||
result = await self._db.execute(stmt)
|
||
orms = result.scalars().all()
|
||
if not orms:
|
||
return 0
|
||
now = utc_now_naive()
|
||
for orm in orms:
|
||
orm.closed_at = now
|
||
orm.updated_at = now
|
||
orm.version += 1
|
||
await self._db.commit()
|
||
return len(orms)
|
||
except IntegrityError as exc:
|
||
raise self._translate_db_error(exc, "channel_session") from exc
|
||
except SQLAlchemyError as exc:
|
||
raise self._translate_db_error(exc, "channel_session") from exc
|
||
except (NotFoundError, ConflictError, DependencyError):
|
||
raise
|
||
except Exception as exc:
|
||
await self._logger.error(
|
||
"channel_session_cleanup_inactive_sessions_failed",
|
||
resource="channel_session",
|
||
error=str(exc),
|
||
)
|
||
raise DependencyError("channel_session", Error(str(exc))) from exc
|
||
|
||
# ═══════════════════════════════════════════════════════════════════════════
|
||
# 配对审批 CRUD(fail-closed)
|
||
# ═══════════════════════════════════════════════════════════════════════════
|
||
|
||
async def createPairing(
|
||
self,
|
||
cmd: CreatePairingCmd,
|
||
tx: TransactionContext | None = None,
|
||
) -> PairingRecord:
|
||
"""创建 PENDING 状态的配对审批记录。
|
||
|
||
需先查询渠道账户获取 int 主键。表故障时 fail-closed,
|
||
异常翻译为 ``DependencyError`` 后向上抛出。
|
||
|
||
Args:
|
||
cmd: 创建配对命令。
|
||
tx: 事务上下文,非空时加入应用层事务,**不得** 自主提交。
|
||
|
||
Raises:
|
||
NotFoundError: 渠道账户不存在。
|
||
ConflictError: 唯一约束冲突。
|
||
DependencyError: 数据库故障(fail-closed)。
|
||
"""
|
||
commit = self._should_commit(tx)
|
||
try:
|
||
account_orm = await self._repos.account.get_by_type_and_account(cmd.channel_type, cmd.account_id)
|
||
if account_orm is None:
|
||
raise NotFoundError("channel_account", cmd.account_id)
|
||
now = utc_now_naive()
|
||
data = pairing_for_write(
|
||
{
|
||
"pairing_id": uuid.uuid4().hex,
|
||
"account_id": account_orm.id,
|
||
"peer_id": cmd.peer_id,
|
||
"peer_name": cmd.peer_name,
|
||
"status": PairingStatus.PENDING.value,
|
||
"expires_at": now + timedelta(seconds=cmd.expires_in_seconds),
|
||
"requested_at": now,
|
||
}
|
||
)
|
||
orm = await self._repos.pairing.create(data, commit=commit)
|
||
return orm_to_pairing(orm, account_orm)
|
||
except IntegrityError as exc:
|
||
raise self._translate_db_error(exc, "channel_pairing") from exc
|
||
except SQLAlchemyError as exc:
|
||
raise self._translate_db_error(exc, "channel_pairing") from exc
|
||
except (NotFoundError, ConflictError, DependencyError):
|
||
# 契约层错误:mappers 翻译的 DependencyError 与本适配器抛出的 NotFoundError/ConflictError
|
||
# 直接放行,不二次翻译,保留原始异常链与错误码
|
||
raise
|
||
except Exception as exc:
|
||
# 兜底:mappers 调用过程中可能抛出 TypeError 等非契约异常,翻译为 DependencyError
|
||
# 防止穿透至核心层(INV-7);保留异常链以便追踪
|
||
await self._logger.error(
|
||
"channel_pairing_create_failed",
|
||
resource="channel_pairing",
|
||
error=str(exc),
|
||
)
|
||
raise DependencyError("channel_pairing", Error(str(exc))) from exc
|
||
|
||
async def getPairing(self, pairing_id: str) -> PairingRecord | None:
|
||
"""按 pairing_id 查询配对审批记录;不存在返回 None。
|
||
|
||
关联查询 ``channel_accounts`` 表填充业务 ``channel_account_id`` 与
|
||
``channel_type``,供管理后台展示与 FR-36 降级检查使用。表故障时
|
||
fail-closed,异常翻译为 ``DependencyError`` 后向上抛出。
|
||
|
||
Raises:
|
||
DependencyError: 数据库故障(fail-closed)。
|
||
"""
|
||
try:
|
||
orm = await self._repos.pairing.get_by_pairing_id(pairing_id)
|
||
if orm is None:
|
||
return None
|
||
account_orm = await self._repos.account.get_by_id(orm.account_id)
|
||
return orm_to_pairing(orm, account_orm)
|
||
except IntegrityError as exc:
|
||
raise self._translate_db_error(exc, "channel_pairing") from exc
|
||
except SQLAlchemyError as exc:
|
||
raise self._translate_db_error(exc, "channel_pairing") from exc
|
||
except (NotFoundError, ConflictError, DependencyError):
|
||
# 契约层错误:mappers 翻译的 DependencyError 与本适配器抛出的 NotFoundError/ConflictError
|
||
# 直接放行,不二次翻译,保留原始异常链与错误码
|
||
raise
|
||
except Exception as exc:
|
||
# 兜底:mappers 调用过程中可能抛出 TypeError 等非契约异常,翻译为 DependencyError
|
||
# 防止穿透至核心层(INV-7);保留异常链以便追踪
|
||
await self._logger.error(
|
||
"channel_pairing_get_failed",
|
||
resource="channel_pairing",
|
||
error=str(exc),
|
||
)
|
||
raise DependencyError("channel_pairing", Error(str(exc))) from exc
|
||
|
||
async def updatePairingStatus(
|
||
self,
|
||
pairing_id: str,
|
||
status: PairingStatus,
|
||
approver_id: str | None = None,
|
||
reason: str | None = None,
|
||
tx: TransactionContext | None = None,
|
||
) -> PairingRecord:
|
||
"""更新配对审批状态。
|
||
|
||
APPROVED/REJECTED/REVOKED/EXPIRED 时填充对应时间戳与 ``reason``。
|
||
不存在时抛 ``NotFoundError``。审计与表故障均 fail-closed。
|
||
|
||
Args:
|
||
pairing_id: 配对 ID。
|
||
status: 新状态。
|
||
approver_id: 审批人 ID(APPROVED/REJECTED 必填)。
|
||
reason: 审批原因 / 拒绝原因 / 撤销原因(可选,非 ``None`` 时写入)。
|
||
tx: 事务上下文,非空时加入应用层事务,**不得** 自主提交。
|
||
|
||
Raises:
|
||
NotFoundError: 配对记录不存在。
|
||
ConflictError: 并发冲突。
|
||
DependencyError: 数据库故障(fail-closed)。
|
||
"""
|
||
commit = self._should_commit(tx)
|
||
try:
|
||
orm = await self._repos.pairing.get_by_pairing_id(pairing_id)
|
||
if orm is None:
|
||
raise NotFoundError("pairing", pairing_id)
|
||
now = utc_now_naive()
|
||
data: dict = {"status": status.value}
|
||
if approver_id is not None:
|
||
data["approver_id"] = approver_id
|
||
if reason is not None:
|
||
data["reason"] = reason
|
||
if status == PairingStatus.APPROVED:
|
||
data["approved_at"] = now
|
||
elif status == PairingStatus.REJECTED:
|
||
data["rejected_at"] = now
|
||
elif status == PairingStatus.REVOKED:
|
||
data["revoked_at"] = now
|
||
elif status == PairingStatus.EXPIRED:
|
||
data["expired_at"] = now
|
||
updated = await self._repos.pairing.update(orm, data, commit=commit)
|
||
account_orm = await self._repos.account.get_by_id(updated.account_id)
|
||
return orm_to_pairing(updated, account_orm)
|
||
except IntegrityError as exc:
|
||
raise self._translate_db_error(exc, "channel_pairing") from exc
|
||
except SQLAlchemyError as exc:
|
||
raise self._translate_db_error(exc, "channel_pairing") from exc
|
||
except (NotFoundError, ConflictError, DependencyError):
|
||
# 契约层错误:mappers 翻译的 DependencyError 与本适配器抛出的 NotFoundError/ConflictError
|
||
# 直接放行,不二次翻译,保留原始异常链与错误码
|
||
raise
|
||
except Exception as exc:
|
||
# 兜底:mappers 调用过程中可能抛出 TypeError 等非契约异常,翻译为 DependencyError
|
||
# 防止穿透至核心层(INV-7);保留异常链以便追踪
|
||
await self._logger.error(
|
||
"channel_pairing_update_status_failed",
|
||
resource="channel_pairing",
|
||
error=str(exc),
|
||
)
|
||
raise DependencyError("channel_pairing", Error(str(exc))) from exc
|
||
|
||
async def getActivePairing(
|
||
self,
|
||
channel_type: ChannelType,
|
||
account_id: str,
|
||
peer_id: str,
|
||
) -> PairingRecord | None:
|
||
"""查询指定渠道账户与对端的 PENDING/APPROVED 配对记录。
|
||
|
||
先查 account ORM 获取主键,再查 active pairing。表故障时
|
||
fail-closed,异常翻译为 ``DependencyError`` 后向上抛出。
|
||
|
||
Raises:
|
||
DependencyError: 数据库故障(fail-closed)。
|
||
"""
|
||
try:
|
||
account_orm = await self._repos.account.get_by_type_and_account(channel_type, account_id)
|
||
if account_orm is None:
|
||
return None
|
||
orm = await self._repos.pairing.get_active_by_account_and_peer(account_orm.id, peer_id)
|
||
if orm is None:
|
||
return None
|
||
return orm_to_pairing(orm, account_orm)
|
||
except IntegrityError as exc:
|
||
raise self._translate_db_error(exc, "channel_pairing") from exc
|
||
except SQLAlchemyError as exc:
|
||
raise self._translate_db_error(exc, "channel_pairing") from exc
|
||
except (NotFoundError, ConflictError, DependencyError):
|
||
# 契约层错误:mappers 翻译的 DependencyError 与本适配器抛出的 NotFoundError/ConflictError
|
||
# 直接放行,不二次翻译,保留原始异常链与错误码
|
||
raise
|
||
except Exception as exc:
|
||
# 兜底:mappers 调用过程中可能抛出 TypeError 等非契约异常,翻译为 DependencyError
|
||
# 防止穿透至核心层(INV-7);保留异常链以便追踪
|
||
await self._logger.error(
|
||
"channel_pairing_get_active_failed",
|
||
resource="channel_pairing",
|
||
error=str(exc),
|
||
)
|
||
raise DependencyError("channel_pairing", Error(str(exc))) from exc
|
||
|
||
async def listExpiredPendingPairings(
|
||
self,
|
||
before: datetime,
|
||
limit: int = 100,
|
||
) -> tuple[PairingRecord, ...]:
|
||
"""列出已过期但仍为 PENDING 的配对记录(过期扫描器)。
|
||
|
||
表故障时 fail-closed,异常翻译为 ``DependencyError`` 后向上抛出。
|
||
|
||
Raises:
|
||
DependencyError: 数据库故障(fail-closed)。
|
||
"""
|
||
try:
|
||
orms = await self._repos.pairing.list_expired_pending(before, limit=limit)
|
||
return tuple(orm_to_pairing(orm) for orm in orms)
|
||
except IntegrityError as exc:
|
||
raise self._translate_db_error(exc, "channel_pairing") from exc
|
||
except SQLAlchemyError as exc:
|
||
raise self._translate_db_error(exc, "channel_pairing") from exc
|
||
except (NotFoundError, ConflictError, DependencyError):
|
||
# 契约层错误:mappers 翻译的 DependencyError 与本适配器抛出的 NotFoundError/ConflictError
|
||
# 直接放行,不二次翻译,保留原始异常链与错误码
|
||
raise
|
||
except Exception as exc:
|
||
# 兜底:mappers 调用过程中可能抛出 TypeError 等非契约异常,翻译为 DependencyError
|
||
# 防止穿透至核心层(INV-7);保留异常链以便追踪
|
||
await self._logger.error(
|
||
"channel_pairing_list_expired_pending_failed",
|
||
resource="channel_pairing",
|
||
error=str(exc),
|
||
)
|
||
raise DependencyError("channel_pairing", Error(str(exc))) from exc
|
||
|
||
async def countPairings(self, query: PairingQuery) -> int:
|
||
"""按条件统计配对审批记录数(``pairing/count`` 操作)。
|
||
|
||
与 ``listPairings`` 过滤语义一致,仅返回计数,供 Dashboard 待办
|
||
角标等轻量场景使用。
|
||
"""
|
||
try:
|
||
account_pks = await self._resolve_pairing_account_pks(query)
|
||
if account_pks is not None and not account_pks:
|
||
return 0
|
||
return await self._repos.pairing.count(
|
||
account_id=account_pks[0] if account_pks and len(account_pks) == 1 else None,
|
||
account_ids=account_pks if account_pks and len(account_pks) > 1 else None,
|
||
peer_id=query.peer_id,
|
||
status=query.status.value if query.status is not None else None,
|
||
start_time=_to_naive_utc(query.created_after),
|
||
end_time=_to_naive_utc(query.created_before),
|
||
)
|
||
except IntegrityError as exc:
|
||
raise self._translate_db_error(exc, "channel_pairing") from exc
|
||
except SQLAlchemyError as exc:
|
||
raise self._translate_db_error(exc, "channel_pairing") from exc
|
||
except (NotFoundError, ConflictError, DependencyError):
|
||
raise
|
||
except Exception as exc:
|
||
await self._logger.error(
|
||
"channel_pairing_count_failed",
|
||
resource="channel_pairing",
|
||
error=str(exc),
|
||
)
|
||
raise DependencyError("channel_pairing", Error(str(exc))) from exc
|
||
|
||
async def _resolve_pairing_account_pks(
|
||
self,
|
||
query: PairingQuery,
|
||
) -> list[int] | None:
|
||
"""解析配对查询中的账户主键列表。
|
||
|
||
``None`` 表示无需按账户过滤(全局查询);空列表表示条件无匹配
|
||
账户,调用方可直接返回 0 / 空元组。
|
||
"""
|
||
if query.channel_type is not None and query.account_id is not None:
|
||
account_orm = await self._repos.account.get_by_type_and_account(query.channel_type, query.account_id)
|
||
if account_orm is None:
|
||
return []
|
||
return [account_orm.id]
|
||
if query.account_id is not None:
|
||
stmt = select(ChannelAccountORM.id).where(
|
||
ChannelAccountORM.account_id == query.account_id,
|
||
ChannelAccountORM.is_deleted == 0,
|
||
)
|
||
result = await self._db.execute(stmt)
|
||
return list(result.scalars().all())
|
||
if query.channel_type is not None:
|
||
stmt = select(ChannelAccountORM.id).where(
|
||
ChannelAccountORM.channel_type == query.channel_type,
|
||
ChannelAccountORM.is_deleted == 0,
|
||
)
|
||
result = await self._db.execute(stmt)
|
||
return list(result.scalars().all())
|
||
return None
|
||
|
||
async def listPairings(self, query: PairingQuery) -> tuple[PairingRecord, ...]:
|
||
"""按条件查询配对审批记录(``pairing/list`` 操作)。
|
||
|
||
``channel_type`` 与 ``account_id`` 共同提供时按两者定位单个账户;
|
||
仅提供 ``account_id`` 时跨渠道查询所有匹配账户(业务 ``account_id``
|
||
非全局唯一,可能跨渠道匹配);二者均缺省时跨账户查询。
|
||
``status`` 为 ``None`` 时返回全部状态。表故障时 fail-closed,
|
||
异常翻译为 ``DependencyError`` 后向上抛出。
|
||
|
||
Args:
|
||
query: 配对查询条件。
|
||
|
||
Raises:
|
||
DependencyError: 数据库故障(fail-closed)。
|
||
"""
|
||
try:
|
||
account_pks = await self._resolve_pairing_account_pks(query)
|
||
if account_pks is not None and not account_pks:
|
||
return ()
|
||
|
||
orms = await self._repos.pairing.list(
|
||
account_id=account_pks[0] if account_pks and len(account_pks) == 1 else None,
|
||
account_ids=account_pks if account_pks and len(account_pks) > 1 else None,
|
||
peer_id=query.peer_id,
|
||
status=query.status.value if query.status is not None else None,
|
||
start_time=_to_naive_utc(query.created_after),
|
||
end_time=_to_naive_utc(query.created_before),
|
||
limit=query.limit,
|
||
offset=query.offset,
|
||
)
|
||
if not orms:
|
||
return ()
|
||
# 批量查询关联账户 ORM,避免 N+1 查询
|
||
account_ids = {orm.account_id for orm in orms}
|
||
account_orms_map: dict[int, ChannelAccountORM] = {}
|
||
if account_ids:
|
||
stmt = select(ChannelAccountORM).where(ChannelAccountORM.id.in_(account_ids))
|
||
result = await self._db.execute(stmt)
|
||
account_orms_map = {a.id: a for a in result.scalars().all()}
|
||
return tuple(orm_to_pairing(orm, account_orms_map.get(orm.account_id)) for orm in orms)
|
||
except IntegrityError as exc:
|
||
raise self._translate_db_error(exc, "channel_pairing") from exc
|
||
except SQLAlchemyError as exc:
|
||
raise self._translate_db_error(exc, "channel_pairing") from exc
|
||
except (NotFoundError, ConflictError, DependencyError):
|
||
# 契约层错误:mappers 翻译的 DependencyError 与本适配器抛出的 NotFoundError/ConflictError
|
||
# 直接放行,不二次翻译,保留原始异常链与错误码
|
||
raise
|
||
except Exception as exc:
|
||
# 兜底:mappers 调用过程中可能抛出 TypeError 等非契约异常,翻译为 DependencyError
|
||
# 防止穿透至核心层(INV-7);保留异常链以便追踪
|
||
await self._logger.error(
|
||
"channel_pairing_list_failed",
|
||
resource="channel_pairing",
|
||
error=str(exc),
|
||
)
|
||
raise DependencyError("channel_pairing", Error(str(exc))) from exc
|
||
|
||
async def getPairingStats(
|
||
self,
|
||
query: PairingStatsQuery,
|
||
) -> PairingStatsResult:
|
||
"""配对统计聚合查询(PRG-STATS)。
|
||
|
||
聚合统计时间范围内的配对申请总数、批准/拒绝/撤销/过期计数、批准率、
|
||
平均审批时长(按 ``approved_at - requested_at`` 计算秒数),并按时间
|
||
粒度分桶返回配对趋势(申请数/批准数双计数)。``query`` 的
|
||
``start_time`` / ``end_time`` 为可选过滤(按 ``requested_at`` 过滤),
|
||
``channel_type`` 通过 JOIN ``ChannelAccount`` 表过滤,
|
||
``granularity`` 控制趋势分桶粒度(hour/day/week)。
|
||
|
||
``avg_approval_seconds`` 仅统计 ``approved`` 状态记录(非 approved
|
||
状态 ``approved_at`` 为 NULL,``EXTRACT(EPOCH FROM NULL)`` 为 NULL,
|
||
``AVG`` 自动跳过 NULL 值)。
|
||
|
||
Args:
|
||
query: 配对统计查询条件(时间范围可选)。
|
||
|
||
Returns:
|
||
``PairingStatsResult`` 聚合结果。
|
||
|
||
Raises:
|
||
DependencyError: 数据库故障。
|
||
"""
|
||
try:
|
||
base_filters: list[Any] = [ChannelPairingORM.is_deleted == 0]
|
||
# DB 时间列为 naive UTC,剥离 DTO 入参的 tzinfo(见 _to_naive_utc 说明)。
|
||
start_naive = _to_naive_utc(query.start_time)
|
||
end_naive = _to_naive_utc(query.end_time)
|
||
if start_naive is not None:
|
||
base_filters.append(ChannelPairingORM.requested_at >= start_naive)
|
||
if end_naive is not None:
|
||
base_filters.append(ChannelPairingORM.requested_at <= end_naive)
|
||
|
||
# 全局聚合:总数 + 各状态计数 + 平均审批时长
|
||
agg_stmt = select(
|
||
func.count(ChannelPairingORM.id).label("total_requested"),
|
||
func.sum(
|
||
case(
|
||
(ChannelPairingORM.status == "approved", 1),
|
||
else_=0,
|
||
)
|
||
).label("approved_count"),
|
||
func.sum(
|
||
case(
|
||
(ChannelPairingORM.status == "rejected", 1),
|
||
else_=0,
|
||
)
|
||
).label("rejected_count"),
|
||
func.sum(
|
||
case(
|
||
(ChannelPairingORM.status == "revoked", 1),
|
||
else_=0,
|
||
)
|
||
).label("revoked_count"),
|
||
func.sum(
|
||
case(
|
||
(ChannelPairingORM.status == "expired", 1),
|
||
else_=0,
|
||
)
|
||
).label("expired_count"),
|
||
func.avg(
|
||
extract(
|
||
"epoch",
|
||
ChannelPairingORM.approved_at - ChannelPairingORM.requested_at,
|
||
)
|
||
).label("avg_approval_seconds"),
|
||
).where(*base_filters)
|
||
if query.channel_type is not None:
|
||
agg_stmt = agg_stmt.join(
|
||
ChannelAccountORM,
|
||
ChannelPairingORM.account_id == ChannelAccountORM.id,
|
||
).where(ChannelAccountORM.channel_type == query.channel_type)
|
||
agg_row = (await self._db.execute(agg_stmt)).one()
|
||
total_requested = int(agg_row.total_requested or 0)
|
||
approved_count = int(agg_row.approved_count or 0)
|
||
rejected_count = int(agg_row.rejected_count or 0)
|
||
revoked_count = int(agg_row.revoked_count or 0)
|
||
expired_count = int(agg_row.expired_count or 0)
|
||
approve_rate = (approved_count / total_requested * 100) if total_requested > 0 else 0.0
|
||
avg_approval_seconds = (
|
||
float(agg_row.avg_approval_seconds) if agg_row.avg_approval_seconds is not None else 0.0
|
||
)
|
||
|
||
# 时间序列:按 granularity 分桶,每桶含申请数 / 批准数双计数
|
||
bucket_expr = func.date_trunc(query.granularity, ChannelPairingORM.requested_at)
|
||
trend_stmt = (
|
||
select(
|
||
bucket_expr.label("bucket"),
|
||
func.count(ChannelPairingORM.id).label("requested"),
|
||
func.sum(
|
||
case(
|
||
(ChannelPairingORM.status == "approved", 1),
|
||
else_=0,
|
||
)
|
||
).label("approved"),
|
||
)
|
||
.where(*base_filters)
|
||
.group_by(bucket_expr)
|
||
.order_by(bucket_expr)
|
||
)
|
||
if query.channel_type is not None:
|
||
trend_stmt = trend_stmt.join(
|
||
ChannelAccountORM,
|
||
ChannelPairingORM.account_id == ChannelAccountORM.id,
|
||
).where(ChannelAccountORM.channel_type == query.channel_type)
|
||
trend_result = await self._db.execute(trend_stmt)
|
||
trend: list[PairingTrendPoint] = [
|
||
PairingTrendPoint(
|
||
timestamp=bucket,
|
||
requested=int(req_val or 0),
|
||
approved=int(appr_val or 0),
|
||
)
|
||
for bucket, req_val, appr_val in trend_result.all()
|
||
]
|
||
|
||
return PairingStatsResult(
|
||
total_requested=total_requested,
|
||
approved_count=approved_count,
|
||
rejected_count=rejected_count,
|
||
revoked_count=revoked_count,
|
||
expired_count=expired_count,
|
||
approve_rate=approve_rate,
|
||
avg_approval_seconds=avg_approval_seconds,
|
||
trend=tuple(trend),
|
||
)
|
||
except IntegrityError as exc:
|
||
raise self._translate_db_error(exc, "channel_pairing") from exc
|
||
except SQLAlchemyError as exc:
|
||
raise self._translate_db_error(exc, "channel_pairing") from exc
|
||
except (NotFoundError, ConflictError, DependencyError):
|
||
# 契约层错误:直接放行,不二次翻译,保留原始异常链与错误码
|
||
raise
|
||
except Exception as exc:
|
||
# 兜底:防止非契约异常穿透至核心层(INV-7);保留异常链以便追踪
|
||
await self._logger.error(
|
||
"channel_pairing_get_stats_failed",
|
||
resource="channel_pairing",
|
||
error=str(exc),
|
||
)
|
||
raise DependencyError("channel_pairing", Error(str(exc))) from exc
|
||
|
||
async def cleanupOldTerminalPairings(self, before: datetime, limit: int) -> int:
|
||
"""物理删除早于指定时间的终态配对记录(FR-33 终态清理)。
|
||
|
||
执行物理删除(非软删除),条件为 ``status IN ("expired", "rejected",
|
||
"revoked")`` AND ``updated_at < before`` AND ``is_deleted == 0``,
|
||
最多删除 ``limit`` 条记录。通过子查询限制删除数量,返回实际删除的
|
||
记录数。表故障时 fail-closed,异常翻译为 ``DependencyError`` 后
|
||
向上抛出。
|
||
|
||
Raises:
|
||
DependencyError: 数据库故障(fail-closed)。
|
||
"""
|
||
try:
|
||
# DB 时间列为 naive UTC,剥离入参的 tzinfo(见 _to_naive_utc 说明)。
|
||
before_naive = _to_naive_utc(before)
|
||
subq = (
|
||
select(ChannelPairingORM.id)
|
||
.where(
|
||
ChannelPairingORM.status.in_(["expired", "rejected", "revoked"]),
|
||
ChannelPairingORM.updated_at < before_naive,
|
||
ChannelPairingORM.is_deleted == 0,
|
||
)
|
||
.limit(limit)
|
||
)
|
||
stmt = delete(ChannelPairingORM).where(ChannelPairingORM.id.in_(subq))
|
||
result = await self._db.execute(stmt)
|
||
await self._db.commit()
|
||
return int(result.rowcount or 0)
|
||
except IntegrityError as exc:
|
||
raise self._translate_db_error(exc, "channel_pairing") from exc
|
||
except SQLAlchemyError as exc:
|
||
raise self._translate_db_error(exc, "channel_pairing") from exc
|
||
except (NotFoundError, ConflictError, DependencyError):
|
||
# 契约层错误:直接放行,不二次翻译,保留原始异常链与错误码
|
||
raise
|
||
except Exception as exc:
|
||
# 兜底:防止非契约异常穿透至核心层(INV-7);保留异常链以便追踪
|
||
await self._logger.error(
|
||
"channel_pairing_cleanup_old_terminal_failed",
|
||
resource="channel_pairing",
|
||
error=str(exc),
|
||
)
|
||
raise DependencyError("channel_pairing", Error(str(exc))) from exc
|
||
|
||
# ═══════════════════════════════════════════════════════════════════════════
|
||
# 审计日志(fail-closed)
|
||
# ═══════════════════════════════════════════════════════════════════════════
|
||
|
||
async def saveAuditLog(
|
||
self,
|
||
cmd: SaveAuditLogCmd,
|
||
tx: TransactionContext | None = None,
|
||
) -> AuditLogId:
|
||
"""保存审计日志,事务内写入,失败必须中止业务。
|
||
|
||
敏感字段脱敏由上层 ``AuditLog`` 聚合根完成(``params_summary`` 已脱敏)。
|
||
``audit_log_id`` 由适配器生成。fail-closed:写入失败抛
|
||
``DependencyError("channel_audit_log", cause)``,不降级、不吞异常。
|
||
|
||
事务边界:``tx`` 非空时加入应用层事务,确保审计日志与业务
|
||
操作在同一事务内(fail-closed 语义要求)。``tx`` 为 ``None`` 时按
|
||
单方法提交(向后兼容)。
|
||
|
||
Args:
|
||
cmd: 保存审计日志命令。
|
||
tx: 事务上下文,非空时加入应用层事务,**不得** 自主提交。
|
||
fail-closed 语义要求审计日志与业务操作在同一事务内,调用方
|
||
应传入 ``tx``。
|
||
|
||
Raises:
|
||
DependencyError: 数据库故障(fail-closed)。
|
||
"""
|
||
commit = self._should_commit(tx)
|
||
data = audit_log_for_write(
|
||
{
|
||
"audit_log_id": uuid.uuid4().hex,
|
||
"operator": cmd.operator,
|
||
"operation": cmd.operation,
|
||
"target": cmd.target,
|
||
"target_channel": cmd.target_channel,
|
||
"target_account": cmd.target_account,
|
||
"result": cmd.result,
|
||
"params_summary": cmd.params_summary or {},
|
||
"trace_id": cmd.trace_id,
|
||
"source_ip": cmd.source_ip,
|
||
"request_id": cmd.request_id,
|
||
"message_id": cmd.message_id,
|
||
"content_summary": cmd.content_summary,
|
||
"timestamp": utc_now_naive(),
|
||
}
|
||
)
|
||
try:
|
||
orm = await self._repos.audit_log.create(data, commit=commit)
|
||
return AuditLogId(orm.audit_log_id)
|
||
except IntegrityError as exc:
|
||
raise self._translate_db_error(exc, "channel_audit_log") from exc
|
||
except SQLAlchemyError as exc:
|
||
raise self._translate_db_error(exc, "channel_audit_log") from exc
|
||
except (NotFoundError, ConflictError, DependencyError):
|
||
# 契约层错误:mappers 翻译的 DependencyError 与本适配器抛出的 NotFoundError/ConflictError
|
||
# 直接放行,不二次翻译,保留原始异常链与错误码
|
||
raise
|
||
except Exception as exc:
|
||
# 兜底:mappers 调用过程中可能抛出 TypeError 等非契约异常,翻译为 DependencyError
|
||
# 防止穿透至核心层(INV-7);保留异常链以便追踪
|
||
await self._logger.error(
|
||
"channel_audit_log_save_failed",
|
||
resource="channel_audit_log",
|
||
error=str(exc),
|
||
)
|
||
raise DependencyError("channel_audit_log", Error(str(exc))) from exc
|
||
|
||
async def queryAuditLogs(self, query: AuditQuery) -> tuple[AuditEntry, ...]:
|
||
"""按条件查询审计日志,按时间倒序。
|
||
|
||
支持 ``AuditQuery`` 全部过滤字段(操作类型 / 操作人 / 目标渠道 /
|
||
目标账户 / 时间范围 / 分页)。查询超时(默认 5s)返回 504。
|
||
|
||
Raises:
|
||
TimeoutError: 查询超时(HTTP 504)。
|
||
DependencyError: 数据库故障。
|
||
"""
|
||
try:
|
||
orms = await asyncio.wait_for(
|
||
self._repos.audit_log.list(
|
||
operation=query.operation_type.value if query.operation_type is not None else None,
|
||
operator=query.operator,
|
||
target_channel=query.target_channel if query.target_channel is not None else None,
|
||
target_account=query.target_account,
|
||
start_time=_to_naive_utc(query.start_time),
|
||
end_time=_to_naive_utc(query.end_time),
|
||
trace_id=query.trace_id,
|
||
limit=query.limit,
|
||
offset=query.offset,
|
||
),
|
||
timeout=5.0,
|
||
)
|
||
return tuple(orm_to_audit_log(orm) for orm in orms)
|
||
except TimeoutError as exc:
|
||
raise OperationTimeoutError(
|
||
timeout_ms=5000,
|
||
message="审计日志查询超时",
|
||
) from exc
|
||
except IntegrityError as exc:
|
||
raise self._translate_db_error(exc, "channel_audit_log") from exc
|
||
except SQLAlchemyError as exc:
|
||
raise self._translate_db_error(exc, "channel_audit_log") from exc
|
||
except (NotFoundError, ConflictError, DependencyError):
|
||
# 契约层错误:mappers 翻译的 DependencyError 与本适配器抛出的 NotFoundError/ConflictError
|
||
# 直接放行,不二次翻译,保留原始异常链与错误码
|
||
raise
|
||
except Exception as exc:
|
||
# 兜底:mappers 调用过程中可能抛出 TypeError 等非契约异常,翻译为 DependencyError
|
||
# 防止穿透至核心层(INV-7);保留异常链以便追踪
|
||
await self._logger.error(
|
||
"channel_audit_log_query_failed",
|
||
resource="channel_audit_log",
|
||
error=str(exc),
|
||
)
|
||
raise DependencyError("channel_audit_log", Error(str(exc))) from exc
|
||
|
||
async def countAuditLogs(self, query: AuditQuery) -> int:
|
||
"""按条件统计审计日志数量,不计 ``limit`` / ``offset`` 分页。
|
||
|
||
与 ``queryAuditLogs`` 共享同一组过滤字段,返回满足条件的总记录数,
|
||
供分页响应携带 ``total`` 字段。查询超时(默认 5s)返回 504。
|
||
|
||
Raises:
|
||
TimeoutError: 查询超时(HTTP 504)。
|
||
DependencyError: 数据库故障。
|
||
"""
|
||
try:
|
||
return await asyncio.wait_for(
|
||
self._repos.audit_log.count(
|
||
operation=query.operation_type.value if query.operation_type is not None else None,
|
||
operator=query.operator,
|
||
target_channel=query.target_channel if query.target_channel is not None else None,
|
||
target_account=query.target_account,
|
||
start_time=_to_naive_utc(query.start_time),
|
||
end_time=_to_naive_utc(query.end_time),
|
||
trace_id=query.trace_id,
|
||
),
|
||
timeout=5.0,
|
||
)
|
||
except TimeoutError as exc:
|
||
raise OperationTimeoutError(
|
||
timeout_ms=5000,
|
||
message="审计日志计数超时",
|
||
) from exc
|
||
except IntegrityError as exc:
|
||
raise self._translate_db_error(exc, "channel_audit_log") from exc
|
||
except SQLAlchemyError as exc:
|
||
raise self._translate_db_error(exc, "channel_audit_log") from exc
|
||
except (NotFoundError, ConflictError, DependencyError):
|
||
# 契约层错误:mappers 翻译的 DependencyError 与本适配器抛出的 NotFoundError/ConflictError
|
||
# 直接放行,不二次翻译,保留原始异常链与错误码
|
||
raise
|
||
except Exception as exc:
|
||
# 兜底:mappers 调用过程中可能抛出 TypeError 等非契约异常,翻译为 DependencyError
|
||
# 防止穿透至核心层(INV-7);保留异常链以便追踪
|
||
await self._logger.error(
|
||
"channel_audit_log_count_failed",
|
||
resource="channel_audit_log",
|
||
error=str(exc),
|
||
)
|
||
raise DependencyError("channel_audit_log", Error(str(exc))) from exc
|
||
|
||
async def deleteOldAuditLogs(
|
||
self,
|
||
before: datetime,
|
||
limit: int = 0,
|
||
) -> int:
|
||
"""物理删除早于指定时间的审计日志(保留期清理)。
|
||
|
||
审计日志为 append-only,直接物理删除。返回删除条数。供
|
||
``ChannelAuditLogRetentionHandler`` 定时清理超过保留期的日志。
|
||
|
||
Args:
|
||
before: 删除截止时间,早于该时间的记录被删除。
|
||
limit: 最大删除数量;0 表示不限制(向后兼容)。
|
||
|
||
Raises:
|
||
DependencyError: 数据库故障。
|
||
"""
|
||
try:
|
||
return await self._repos.audit_log.delete_old_logs(
|
||
before,
|
||
limit=limit,
|
||
commit=True,
|
||
)
|
||
except IntegrityError as exc:
|
||
raise self._translate_db_error(exc, "channel_audit_log") from exc
|
||
except SQLAlchemyError as exc:
|
||
raise self._translate_db_error(exc, "channel_audit_log") from exc
|
||
|
||
async def getAuditLog(
|
||
self,
|
||
log_id: str,
|
||
) -> AuditEntry | None:
|
||
"""按 ``audit_log_id`` 查询单条审计日志;不存在返回 None。
|
||
|
||
查询超时(默认 5s)返回 504。
|
||
|
||
Args:
|
||
log_id: 审计日志业务 ID(UUID 字符串)。
|
||
|
||
Raises:
|
||
TimeoutError: 查询超时(HTTP 504)。
|
||
DependencyError: 数据库故障。
|
||
"""
|
||
try:
|
||
orm = await asyncio.wait_for(
|
||
self._repos.audit_log.get_by_audit_log_id(log_id),
|
||
timeout=5.0,
|
||
)
|
||
if orm is None:
|
||
return None
|
||
return orm_to_audit_log(orm)
|
||
except TimeoutError as exc:
|
||
raise OperationTimeoutError(
|
||
timeout_ms=5000,
|
||
message="审计日志查询超时",
|
||
) from exc
|
||
except IntegrityError as exc:
|
||
raise self._translate_db_error(exc, "channel_audit_log") from exc
|
||
except SQLAlchemyError as exc:
|
||
raise self._translate_db_error(exc, "channel_audit_log") from exc
|
||
except (NotFoundError, ConflictError, DependencyError):
|
||
# 契约层错误:mappers 翻译的 DependencyError 与本适配器抛出的 NotFoundError/ConflictError
|
||
# 直接放行,不二次翻译,保留原始异常链与错误码
|
||
raise
|
||
except Exception as exc:
|
||
# 兜底:mappers 调用过程中可能抛出 TypeError 等非契约异常,翻译为 DependencyError
|
||
# 防止穿透至核心层(INV-7);保留异常链以便追踪
|
||
await self._logger.error(
|
||
"channel_audit_log_get_failed",
|
||
resource="channel_audit_log",
|
||
error=str(exc),
|
||
)
|
||
raise DependencyError("channel_audit_log", Error(str(exc))) from exc
|
||
|
||
async def getAuditLogStats(
|
||
self,
|
||
query: AuditQuery,
|
||
) -> AuditLogStats:
|
||
"""按条件统计审计日志聚合(FR-34)。
|
||
|
||
按 ``operation_type`` / ``result`` 分组聚合,返回统计结果。统计查询不含
|
||
``limit`` / ``offset`` 分页参数,对全量匹配数据聚合。查询超时(默认 5s)
|
||
返回 504。
|
||
|
||
Args:
|
||
query: 审计查询条件(``limit`` / ``offset`` 字段被忽略)。
|
||
|
||
Raises:
|
||
TimeoutError: 查询超时(HTTP 504)。
|
||
DependencyError: 数据库故障。
|
||
"""
|
||
try:
|
||
stats_data = await asyncio.wait_for(
|
||
self._repos.audit_log.get_stats(
|
||
operation=query.operation_type.value if query.operation_type is not None else None,
|
||
operator=query.operator,
|
||
target_channel=query.target_channel if query.target_channel is not None else None,
|
||
target_account=query.target_account,
|
||
start_time=_to_naive_utc(query.start_time),
|
||
end_time=_to_naive_utc(query.end_time),
|
||
),
|
||
timeout=5.0,
|
||
)
|
||
return AuditLogStats(
|
||
total=stats_data["total"],
|
||
by_operation_type=stats_data["by_operation"],
|
||
by_result=stats_data["by_result"],
|
||
time_range_start=stats_data["time_start"],
|
||
time_range_end=stats_data["time_end"],
|
||
)
|
||
except TimeoutError as exc:
|
||
raise OperationTimeoutError(
|
||
timeout_ms=5000,
|
||
message="审计日志统计超时",
|
||
) from exc
|
||
except IntegrityError as exc:
|
||
raise self._translate_db_error(exc, "channel_audit_log") from exc
|
||
except SQLAlchemyError as exc:
|
||
raise self._translate_db_error(exc, "channel_audit_log") from exc
|
||
except (NotFoundError, ConflictError, DependencyError):
|
||
# 契约层错误:mappers 翻译的 DependencyError 与本适配器抛出的 NotFoundError/ConflictError
|
||
# 直接放行,不二次翻译,保留原始异常链与错误码
|
||
raise
|
||
except Exception as exc:
|
||
# 兜底:mappers 调用过程中可能抛出 TypeError 等非契约异常,翻译为 DependencyError
|
||
# 防止穿透至核心层(INV-7);保留异常链以便追踪
|
||
await self._logger.error(
|
||
"channel_audit_log_get_stats_failed",
|
||
resource="channel_audit_log",
|
||
error=str(exc),
|
||
)
|
||
raise DependencyError("channel_audit_log", Error(str(exc))) from exc
|
||
|
||
# ═══════════════════════════════════════════════════════════════════════════
|
||
# Outbox CRUD
|
||
# ═══════════════════════════════════════════════════════════════════════════
|
||
|
||
async def saveOutboxEntry(
|
||
self,
|
||
cmd: SaveOutboxEntryCmd,
|
||
tx: TransactionContext | None = None,
|
||
) -> OutboxId:
|
||
"""创建 PENDING 状态的发件箱条目。
|
||
|
||
仅存消息引用(``message_id``),不存储完整消息内容。
|
||
``conversation_id`` 从 Message 表查询注入(ORM 要求非空)。
|
||
``channel_session_id`` 从 cmd 业务标识解析为 ORM 主键 int 注入
|
||
(ORM 注释约定由 persistence 适配器从管道上下文注入),未提供时
|
||
为 NULL(重试 worker 回退到账户下最近会话查询)。
|
||
``expires_at`` 与 ``max_retry`` 从注入的 ``OutboxConfig`` 读取
|
||
(``self._outbox_config``),避免硬编码与对全局配置的依赖。
|
||
|
||
Args:
|
||
cmd: 保存发件箱条目命令。
|
||
tx: 事务上下文,非空时加入应用层事务,**不得** 自主提交。
|
||
|
||
Raises:
|
||
NotFoundError: 消息或渠道会话不存在。
|
||
ConflictError: 唯一约束冲突。
|
||
DependencyError: 数据库故障。
|
||
"""
|
||
commit = self._should_commit(tx)
|
||
try:
|
||
message_id = int(cmd.message_id)
|
||
except (TypeError, ValueError) as exc:
|
||
raise NotFoundError("message", cmd.message_id) from exc
|
||
try:
|
||
account_id = int(cmd.channel_account_id)
|
||
except (TypeError, ValueError) as exc:
|
||
raise NotFoundError("channel_account", cmd.channel_account_id) from exc
|
||
try:
|
||
msg_stmt = select(MessageORM.conversation_id).where(MessageORM.id == message_id)
|
||
msg_result = await self._db.execute(msg_stmt)
|
||
conversation_id = msg_result.scalar_one_or_none()
|
||
if conversation_id is None:
|
||
raise NotFoundError("message", cmd.message_id)
|
||
# cmd.channel_session_id 为业务标识 UUID 字符串,解析为 ORM
|
||
# 主键 int 后写入 ORM 列。未提供时为 NULL,重试 worker 回退
|
||
# 到账户下最近会话查询(outbox_retry_worker._resolvePeerId)。
|
||
channel_session_pk: int | None = None
|
||
if cmd.channel_session_id:
|
||
session_orm = await self._repos.session.get_by_session_id(cmd.channel_session_id)
|
||
if session_orm is None:
|
||
raise NotFoundError("channel_session", cmd.channel_session_id)
|
||
channel_session_pk = session_orm.id
|
||
now = utc_now_naive()
|
||
ttl_seconds = self._outbox_config.ttl_seconds
|
||
max_retry = self._outbox_config.max_retry
|
||
data = outbox_entry_for_write(
|
||
{
|
||
"outbox_id": uuid.uuid4().hex,
|
||
"message_id": message_id,
|
||
"account_id": account_id,
|
||
"conversation_id": conversation_id,
|
||
"channel_session_id": channel_session_pk,
|
||
"status": "pending",
|
||
"durability_policy": cmd.durability_policy,
|
||
"max_retry": max_retry,
|
||
"expires_at": now + timedelta(seconds=ttl_seconds),
|
||
"trace_id": cmd.trace_id,
|
||
}
|
||
)
|
||
orm = await self._repos.outbox.create(data, commit=commit)
|
||
return OutboxId(orm.outbox_id)
|
||
except IntegrityError as exc:
|
||
raise self._translate_db_error(exc, "channel_outbox") from exc
|
||
except SQLAlchemyError as exc:
|
||
raise self._translate_db_error(exc, "channel_outbox") from exc
|
||
except (NotFoundError, ConflictError, DependencyError):
|
||
# 契约层错误:mappers 翻译的 DependencyError 与本适配器抛出的 NotFoundError/ConflictError
|
||
# 直接放行,不二次翻译,保留原始异常链与错误码
|
||
raise
|
||
except Exception as exc:
|
||
# 兜底:mappers 调用过程中可能抛出 TypeError 等非契约异常,翻译为 DependencyError
|
||
# 防止穿透至核心层(INV-7);保留异常链以便追踪
|
||
await self._logger.error(
|
||
"channel_outbox_save_entry_failed",
|
||
resource="channel_outbox",
|
||
error=str(exc),
|
||
)
|
||
raise DependencyError("channel_outbox", Error(str(exc))) from exc
|
||
|
||
async def getOutboxEntry(self, outbox_id: str) -> OutboxEntry | None:
|
||
"""按 outbox_id 查询发件箱条目;不存在返回 None。
|
||
|
||
Raises:
|
||
DependencyError: 数据库故障。
|
||
"""
|
||
try:
|
||
orm = await self._repos.outbox.get_by_outbox_id(outbox_id)
|
||
if orm is None:
|
||
return None
|
||
return orm_to_outbox_entry(orm)
|
||
except IntegrityError as exc:
|
||
raise self._translate_db_error(exc, "channel_outbox") from exc
|
||
except SQLAlchemyError as exc:
|
||
raise self._translate_db_error(exc, "channel_outbox") from exc
|
||
except (NotFoundError, ConflictError, DependencyError):
|
||
# 契约层错误:mappers 翻译的 DependencyError 与本适配器抛出的 NotFoundError/ConflictError
|
||
# 直接放行,不二次翻译,保留原始异常链与错误码
|
||
raise
|
||
except Exception as exc:
|
||
# 兜底:mappers 调用过程中可能抛出 TypeError 等非契约异常,翻译为 DependencyError
|
||
# 防止穿透至核心层(INV-7);保留异常链以便追踪
|
||
await self._logger.error(
|
||
"channel_outbox_get_entry_failed",
|
||
resource="channel_outbox",
|
||
error=str(exc),
|
||
)
|
||
raise DependencyError("channel_outbox", Error(str(exc))) from exc
|
||
|
||
async def getOutboxEntryByChannelMsgId(
|
||
self,
|
||
channel_msg_id: str,
|
||
) -> OutboxEntry | None:
|
||
"""按渠道消息 ID 查询发件箱条目(状态回写定位)。
|
||
|
||
用于入站状态路由阶段在 Message 表未找到对应消息时,通过渠道消息 ID
|
||
反查持久化投递记录,定位关联的 Message 记录。
|
||
|
||
Raises:
|
||
DependencyError: 数据库故障。
|
||
"""
|
||
try:
|
||
orm = await self._repos.outbox.get_by_channel_msg_id(channel_msg_id)
|
||
if orm is None:
|
||
return None
|
||
return orm_to_outbox_entry(orm)
|
||
except IntegrityError as exc:
|
||
raise self._translate_db_error(exc, "channel_outbox") from exc
|
||
except SQLAlchemyError as exc:
|
||
raise self._translate_db_error(exc, "channel_outbox") from exc
|
||
except (NotFoundError, ConflictError, DependencyError):
|
||
# 契约层错误:mappers 翻译的 DependencyError 与本适配器抛出的 NotFoundError/ConflictError
|
||
# 直接放行,不二次翻译,保留原始异常链与错误码
|
||
raise
|
||
except Exception as exc:
|
||
# 兜底:mappers 调用过程中可能抛出 TypeError 等非契约异常,翻译为 DependencyError
|
||
# 防止穿透至核心层(INV-7);保留异常链以便追踪
|
||
await self._logger.error(
|
||
"channel_outbox_get_entry_by_channel_msg_id_failed",
|
||
resource="channel_outbox",
|
||
error=str(exc),
|
||
)
|
||
raise DependencyError("channel_outbox", Error(str(exc))) from exc
|
||
|
||
async def updateOutboxEntry(
|
||
self,
|
||
entry: OutboxEntry,
|
||
tx: TransactionContext | None = None,
|
||
) -> OutboxEntry:
|
||
"""完整更新发件箱条目(含 retry_count/next_retry_at/last_error)。
|
||
|
||
补偿阶段聚合根内部计算的重试状态通过本方法完整持久化。
|
||
通过 ``entry.version`` 实现乐观锁:若持久化版本与 ``entry.version`` 不一致
|
||
则抛出 ``ConflictError``。返回更新后的 ``OutboxEntry``(version 递增)。
|
||
|
||
实现要点:
|
||
- 使用 ``for_update=True`` 加行级锁,保证版本检查与更新之间的原子性。
|
||
- 直接设置 ORM 字段(含可能为 None 的 next_retry_at / last_error /
|
||
channel_msg_id),绕过仓储 ``update`` 方法跳过 None 值的限制。
|
||
- ``version`` 由本方法递增(仓储 ``update`` 方法的 updatable_fields 不含
|
||
version),再委托仓储 ``update`` 完成提交与刷新。
|
||
|
||
Args:
|
||
entry: 发件箱条目(含完整字段)。
|
||
tx: 事务上下文,非空时加入应用层事务,**不得** 自主提交。
|
||
|
||
Raises:
|
||
NotFoundError: 发件箱条目不存在。
|
||
ConflictError: 乐观锁版本不匹配或并发冲突。
|
||
DependencyError: 数据库故障。
|
||
"""
|
||
commit = self._should_commit(tx)
|
||
try:
|
||
orm = await self._repos.outbox.get_by_outbox_id(entry.outbox_id, for_update=True)
|
||
if orm is None:
|
||
raise NotFoundError("outbox_entry", entry.outbox_id)
|
||
if orm.version != entry.version:
|
||
raise ConflictError("outbox_entry")
|
||
orm.status = entry.status.value
|
||
orm.retry_count = entry.retry_count
|
||
orm.next_retry_at = entry.next_retry_at
|
||
orm.last_error = entry.last_error
|
||
orm.channel_msg_id = entry.channel_msg_id
|
||
orm.version += 1
|
||
updated = await self._repos.outbox.update(orm, {}, commit=commit)
|
||
return orm_to_outbox_entry(updated)
|
||
except IntegrityError as exc:
|
||
raise self._translate_db_error(exc, "channel_outbox") from exc
|
||
except SQLAlchemyError as exc:
|
||
raise self._translate_db_error(exc, "channel_outbox") from exc
|
||
except (NotFoundError, ConflictError, DependencyError):
|
||
# 契约层错误:mappers 翻译的 DependencyError 与本适配器抛出的 NotFoundError/ConflictError
|
||
# 直接放行,不二次翻译,保留原始异常链与错误码
|
||
raise
|
||
except Exception as exc:
|
||
# 兜底:mappers 调用过程中可能抛出 TypeError 等非契约异常,翻译为 DependencyError
|
||
# 防止穿透至核心层(INV-7);保留异常链以便追踪
|
||
await self._logger.error(
|
||
"channel_outbox_update_entry_failed",
|
||
resource="channel_outbox",
|
||
error=str(exc),
|
||
)
|
||
raise DependencyError("channel_outbox", Error(str(exc))) from exc
|
||
|
||
async def listPendingOutboxEntries(self, limit: int = 100) -> tuple[OutboxEntry, ...]:
|
||
"""列出 PENDING 状态的发件箱条目,供 Worker 拉取投递。
|
||
|
||
Raises:
|
||
DependencyError: 数据库故障。
|
||
"""
|
||
try:
|
||
orms = await self._repos.outbox.list_pending(limit=limit)
|
||
return tuple(orm_to_outbox_entry(orm) for orm in orms)
|
||
except IntegrityError as exc:
|
||
raise self._translate_db_error(exc, "channel_outbox") from exc
|
||
except SQLAlchemyError as exc:
|
||
raise self._translate_db_error(exc, "channel_outbox") from exc
|
||
except (NotFoundError, ConflictError, DependencyError):
|
||
# 契约层错误:mappers 翻译的 DependencyError 与本适配器抛出的 NotFoundError/ConflictError
|
||
# 直接放行,不二次翻译,保留原始异常链与错误码
|
||
raise
|
||
except Exception as exc:
|
||
# 兜底:mappers 调用过程中可能抛出 TypeError 等非契约异常,翻译为 DependencyError
|
||
# 防止穿透至核心层(INV-7);保留异常链以便追踪
|
||
await self._logger.error(
|
||
"channel_outbox_list_pending_entries_failed",
|
||
resource="channel_outbox",
|
||
error=str(exc),
|
||
)
|
||
raise DependencyError("channel_outbox", Error(str(exc))) from exc
|
||
|
||
async def listSentUnconfirmedOutboxEntries(self, limit: int = 100) -> tuple[OutboxEntry, ...]:
|
||
"""列出 SENT_UNCONFIRMED 状态的发件箱条目。
|
||
|
||
供恢复扫描器识别"可能已发送但无回执"的条目,进程中断后重新入队重试。
|
||
|
||
Raises:
|
||
DependencyError: 数据库故障。
|
||
"""
|
||
try:
|
||
orms = await self._repos.outbox.list_sent_unconfirmed(limit=limit)
|
||
return tuple(orm_to_outbox_entry(orm) for orm in orms)
|
||
except IntegrityError as exc:
|
||
raise self._translate_db_error(exc, "channel_outbox") from exc
|
||
except SQLAlchemyError as exc:
|
||
raise self._translate_db_error(exc, "channel_outbox") from exc
|
||
except (NotFoundError, ConflictError, DependencyError):
|
||
# 契约层错误:mappers 翻译的 DependencyError 与本适配器抛出的 NotFoundError/ConflictError
|
||
# 直接放行,不二次翻译,保留原始异常链与错误码
|
||
raise
|
||
except Exception as exc:
|
||
# 兜底:mappers 调用过程中可能抛出 TypeError 等非契约异常,翻译为 DependencyError
|
||
# 防止穿透至核心层(INV-7);保留异常链以便追踪
|
||
await self._logger.error(
|
||
"channel_outbox_list_sent_unconfirmed_entries_failed",
|
||
resource="channel_outbox",
|
||
error=str(exc),
|
||
)
|
||
raise DependencyError("channel_outbox", Error(str(exc))) from exc
|
||
|
||
async def resolveRetryContext(self, outbox_id: str) -> RetryContext:
|
||
"""解析 Outbox 条目的重试上下文(FR-22)。
|
||
|
||
下沉 ``OutboxRetryWorker._resolveRetryContext`` 与 ``_resolvePeerId``
|
||
的 ORM 解析逻辑:根据 ``outbox_id`` 查询 Outbox ORM,关联渠道账户
|
||
获取 ``channel_type`` 与业务 ``account_id``,关联渠道会话获取
|
||
``peer_id``,关联消息表获取消息内容并封装为 ``FormattedMessage``。
|
||
枚举字段通过 ``orm_to_channel_account`` mapper 安全构造(``_enum``
|
||
helper 翻译非法值为 ``DependencyError``)。
|
||
|
||
解析顺序:
|
||
1. 查询 ``ChannelOutboxEntry`` ORM 获取 ``account_id`` /
|
||
``message_id`` / ``channel_session_id`` 三个 int 外键。
|
||
2. 查询 ``ChannelAccount`` ORM,经 ``orm_to_channel_account``
|
||
转换为 DTO 后提取 ``channel_type`` 与业务 ``account_id``。
|
||
3. 解析 ``peer_id``:优先按 ``channel_session_id`` 精确定位
|
||
渠道会话;缺失时回退按账户主键查询最近一条未软删除会话。
|
||
4. 查询 ``Message`` ORM 获取消息内容,封装为 ``FormattedMessage``
|
||
返回,保留后续扩展 ``format`` / ``attachments`` 的字段空间。
|
||
|
||
Args:
|
||
outbox_id: Outbox 条目业务 ID。
|
||
|
||
Returns:
|
||
``RetryContext`` DTO,含重试所需渠道上下文。
|
||
|
||
Raises:
|
||
NotFoundError: Outbox 条目、关联渠道账户、渠道会话或消息不存在。
|
||
DependencyError: 数据库故障或枚举字段非法值。
|
||
"""
|
||
try:
|
||
outbox_orm = await self._db.scalar(
|
||
select(ChannelOutboxEntryORM).where(ChannelOutboxEntryORM.outbox_id == outbox_id)
|
||
)
|
||
if outbox_orm is None:
|
||
raise NotFoundError("outbox_entry", outbox_id)
|
||
|
||
account_pk = outbox_orm.account_id
|
||
message_pk = outbox_orm.message_id
|
||
channel_session_id = outbox_orm.channel_session_id
|
||
|
||
account_orm = await self._db.scalar(select(ChannelAccountORM).where(ChannelAccountORM.id == account_pk))
|
||
if account_orm is None:
|
||
raise NotFoundError("channel_account", str(account_pk))
|
||
|
||
# 通过 orm_to_channel_account 复用 _enum helper 安全构造枚举,
|
||
# 避免非法 channel_type 值穿透为原生 ValueError(INV-7)。
|
||
account_dto = orm_to_channel_account(account_orm)
|
||
|
||
peer_id = await self._resolvePeerId(account_pk, channel_session_id)
|
||
|
||
message_orm = await self._db.scalar(select(MessageORM).where(MessageORM.id == message_pk))
|
||
if message_orm is None:
|
||
raise NotFoundError("message", str(message_pk))
|
||
|
||
return RetryContext(
|
||
channel_type=account_dto.channel_type,
|
||
account_id=account_dto.account_id,
|
||
peer_id=peer_id,
|
||
message=FormattedMessage(content=message_orm.content),
|
||
)
|
||
except IntegrityError as exc:
|
||
raise self._translate_db_error(exc, "outbox_retry_context") from exc
|
||
except SQLAlchemyError as exc:
|
||
raise self._translate_db_error(exc, "outbox_retry_context") from exc
|
||
except (NotFoundError, ConflictError, DependencyError):
|
||
# 契约层错误:本适配器抛出的 NotFoundError 与 mappers 翻译的
|
||
# DependencyError 直接放行,不二次翻译,保留原始异常链与错误码
|
||
raise
|
||
except Exception as exc:
|
||
# 兜底:mappers 调用过程中可能抛出 TypeError 等非契约异常,翻译为 DependencyError
|
||
# 防止穿透至核心层(INV-7);保留异常链以便追踪
|
||
await self._logger.error(
|
||
"outbox_retry_context_resolve_failed",
|
||
resource="outbox_retry_context",
|
||
error=str(exc),
|
||
)
|
||
raise DependencyError("outbox_retry_context", Error(str(exc))) from exc
|
||
|
||
async def _resolvePeerId(self, account_pk: int, channel_session_id: int | None) -> str:
|
||
"""解析对端 ID(FR-22)。
|
||
|
||
优先按 ``channel_session_id`` 精确定位渠道会话;若 Outbox 条目未
|
||
记录 ``channel_session_id``,回退按渠道账户主键查询最近一条未
|
||
软删除的会话。
|
||
|
||
Args:
|
||
account_pk: 渠道账户 ORM 主键。
|
||
channel_session_id: 渠道会话 ORM 主键(可选)。
|
||
|
||
Returns:
|
||
对端 ID。
|
||
|
||
Raises:
|
||
NotFoundError: 渠道会话不存在。
|
||
DependencyError: 数据库故障。
|
||
"""
|
||
if channel_session_id is not None:
|
||
session_orm = await self._db.scalar(
|
||
select(ChannelSessionORM).where(ChannelSessionORM.id == channel_session_id)
|
||
)
|
||
if session_orm is not None:
|
||
return session_orm.peer_id
|
||
|
||
session_orm = await self._db.scalar(
|
||
select(ChannelSessionORM)
|
||
.where(
|
||
ChannelSessionORM.account_id == account_pk,
|
||
ChannelSessionORM.is_deleted == 0,
|
||
)
|
||
.order_by(ChannelSessionORM.updated_at.desc())
|
||
.limit(1)
|
||
)
|
||
if session_orm is None:
|
||
raise NotFoundError("channel_session", str(account_pk))
|
||
return session_orm.peer_id
|
||
|
||
def _ensureChannelAccountJoin(self, stmt: Select[tuple]) -> Select[tuple]:
|
||
"""若 SELECT 尚未关联 ``channel_accounts`` 表,则执行 JOIN。
|
||
|
||
供 ``_applyOutboxQueryFilter`` / ``listOutboxEntries`` 复用,避免
|
||
重复 JOIN 导致 SQLAlchemy 生成冗余表引用。
|
||
"""
|
||
if ChannelAccountORM not in stmt.froms:
|
||
stmt = stmt.join(
|
||
ChannelAccountORM,
|
||
ChannelOutboxEntryORM.account_id == ChannelAccountORM.id,
|
||
)
|
||
return stmt
|
||
|
||
def _applyOutboxQueryFilter(
|
||
self,
|
||
stmt: Select[tuple],
|
||
query_filter: OutboxQueryFilter,
|
||
) -> Select[tuple] | None:
|
||
"""应用 outbox 查询过滤条件到 SELECT 语句。
|
||
|
||
提取 ``listOutboxEntries`` / ``countOutboxEntries`` 共享的过滤逻辑,
|
||
消除重复代码(DRY)。``channel_type`` / ``channel_account_id`` 通过
|
||
JOIN ``ChannelAccount`` 表过滤(Outbox 表无 ``channel_type`` 列);
|
||
``message_id`` 字符串转 int 后比较,转换失败返回 ``None`` 表示
|
||
无匹配可能(调用方据此短路返回空结果)。新增 ``*_like`` 字段支持
|
||
模糊搜索与 ``last_error`` 关键词筛选。
|
||
|
||
Args:
|
||
stmt: 已包含 ``is_deleted == 0`` 基础过滤的 SELECT 语句。
|
||
``listOutboxEntries`` 可能已预先 JOIN ``channel_accounts``。
|
||
query_filter: 查询过滤条件。
|
||
|
||
Returns:
|
||
应用了全部过滤条件的 SELECT 语句;``message_id`` 转换失败时
|
||
返回 ``None``,调用方应短路返回空结果。
|
||
"""
|
||
needs_account_join = (
|
||
query_filter.channel_type is not None
|
||
or query_filter.channel_account_id is not None
|
||
or query_filter.channel_account_id_like is not None
|
||
)
|
||
if needs_account_join:
|
||
stmt = self._ensureChannelAccountJoin(stmt)
|
||
if query_filter.channel_type is not None:
|
||
stmt = stmt.where(ChannelAccountORM.channel_type == query_filter.channel_type)
|
||
if query_filter.channel_account_id is not None:
|
||
stmt = stmt.where(ChannelAccountORM.account_id == query_filter.channel_account_id)
|
||
if query_filter.channel_account_id_like is not None:
|
||
pattern = f"%{_escape_like_pattern(query_filter.channel_account_id_like)}%"
|
||
stmt = stmt.where(ChannelAccountORM.account_id.like(pattern, escape="\\"))
|
||
if query_filter.status is not None:
|
||
stmt = stmt.where(ChannelOutboxEntryORM.status == query_filter.status.value)
|
||
if query_filter.message_id is not None:
|
||
try:
|
||
message_id = int(query_filter.message_id)
|
||
except (TypeError, ValueError):
|
||
return None
|
||
stmt = stmt.where(ChannelOutboxEntryORM.message_id == message_id)
|
||
if query_filter.message_id_like is not None:
|
||
pattern = f"%{_escape_like_pattern(query_filter.message_id_like)}%"
|
||
stmt = stmt.where(func.cast(ChannelOutboxEntryORM.message_id, String).like(pattern, escape="\\"))
|
||
if query_filter.channel_msg_id is not None:
|
||
stmt = stmt.where(ChannelOutboxEntryORM.channel_msg_id == query_filter.channel_msg_id)
|
||
if query_filter.channel_msg_id_like is not None:
|
||
pattern = f"%{_escape_like_pattern(query_filter.channel_msg_id_like)}%"
|
||
stmt = stmt.where(ChannelOutboxEntryORM.channel_msg_id.like(pattern, escape="\\"))
|
||
if query_filter.channel_session_id is not None:
|
||
try:
|
||
session_id = int(query_filter.channel_session_id)
|
||
except (TypeError, ValueError):
|
||
return None
|
||
stmt = stmt.where(ChannelOutboxEntryORM.channel_session_id == session_id)
|
||
if query_filter.created_after is not None:
|
||
stmt = stmt.where(ChannelOutboxEntryORM.created_at >= _to_naive_utc(query_filter.created_after))
|
||
if query_filter.created_before is not None:
|
||
stmt = stmt.where(ChannelOutboxEntryORM.created_at <= _to_naive_utc(query_filter.created_before))
|
||
if query_filter.retry_count_min is not None:
|
||
stmt = stmt.where(ChannelOutboxEntryORM.retry_count >= query_filter.retry_count_min)
|
||
if query_filter.last_error_like is not None:
|
||
pattern = f"%{_escape_like_pattern(query_filter.last_error_like)}%"
|
||
stmt = stmt.where(ChannelOutboxEntryORM.last_error.like(pattern, escape="\\"))
|
||
return stmt
|
||
|
||
async def listOutboxEntries(
|
||
self,
|
||
query_filter: OutboxQueryFilter,
|
||
limit: int = 100,
|
||
offset: int = 0,
|
||
) -> tuple[OutboxEntry, ...]:
|
||
"""按过滤条件列出 outbox 条目(仅含 is_deleted=0)。
|
||
|
||
按 created_at 倒序分页。过滤条件通过 ``_applyOutboxQueryFilter``
|
||
统一应用,``message_id`` 转换失败时短路返回空元组(无匹配可能)。
|
||
查询始终 JOIN ``channel_accounts`` 表,返回每条记录的 ``channel_type``。
|
||
|
||
Raises:
|
||
DependencyError: 数据库故障。
|
||
"""
|
||
try:
|
||
stmt = (
|
||
select(ChannelOutboxEntryORM, ChannelAccountORM.channel_type)
|
||
.join(ChannelAccountORM, ChannelOutboxEntryORM.account_id == ChannelAccountORM.id)
|
||
.where(ChannelOutboxEntryORM.is_deleted == 0)
|
||
)
|
||
stmt = self._applyOutboxQueryFilter(stmt, query_filter)
|
||
if stmt is None:
|
||
return ()
|
||
stmt = stmt.order_by(ChannelOutboxEntryORM.created_at.desc()).limit(limit).offset(offset)
|
||
result = await self._db.execute(stmt)
|
||
rows = result.all()
|
||
return tuple(
|
||
orm_to_outbox_entry(
|
||
row[0],
|
||
channel_type=ChannelType(row[1]) if row[1] is not None else None,
|
||
)
|
||
for row in rows
|
||
)
|
||
except IntegrityError as exc:
|
||
raise self._translate_db_error(exc, "channel_outbox") from exc
|
||
except SQLAlchemyError as exc:
|
||
raise self._translate_db_error(exc, "channel_outbox") from exc
|
||
except (NotFoundError, ConflictError, DependencyError):
|
||
# 契约层错误:mappers 翻译的 DependencyError 直接放行,不二次翻译,
|
||
# 保留原始异常链与错误码
|
||
raise
|
||
except Exception as exc:
|
||
# 兜底:mappers 调用过程中可能抛出 TypeError 等非契约异常,翻译为
|
||
# DependencyError 防止穿透至核心层(INV-7);保留异常链以便追踪
|
||
await self._logger.error(
|
||
"channel_outbox_list_entries_failed",
|
||
resource="channel_outbox",
|
||
error=str(exc),
|
||
)
|
||
raise DependencyError("channel_outbox", Error(str(exc))) from exc
|
||
|
||
async def listOutboxEntriesByIds(
|
||
self,
|
||
outbox_ids: list[str],
|
||
) -> tuple[OutboxEntry, ...]:
|
||
"""按 outbox_id 列表批量查询发件箱条目(仅含 is_deleted=0)。
|
||
|
||
供批量操作按指定 ID 精确查询,避免 ``listOutboxEntries`` 分页截断
|
||
导致目标条目漏查。按 created_at 倒序,不限状态。
|
||
|
||
Raises:
|
||
DependencyError: 数据库故障。
|
||
"""
|
||
if not outbox_ids:
|
||
return ()
|
||
try:
|
||
stmt = (
|
||
select(ChannelOutboxEntryORM)
|
||
.where(
|
||
ChannelOutboxEntryORM.is_deleted == 0,
|
||
ChannelOutboxEntryORM.outbox_id.in_(outbox_ids),
|
||
)
|
||
.order_by(ChannelOutboxEntryORM.created_at.desc())
|
||
)
|
||
result = await self._db.execute(stmt)
|
||
orms = result.scalars().all()
|
||
return tuple(orm_to_outbox_entry(orm) for orm in orms)
|
||
except IntegrityError as exc:
|
||
raise self._translate_db_error(exc, "channel_outbox") from exc
|
||
except SQLAlchemyError as exc:
|
||
raise self._translate_db_error(exc, "channel_outbox") from exc
|
||
except (NotFoundError, ConflictError, DependencyError):
|
||
raise
|
||
except Exception as exc:
|
||
await self._logger.error(
|
||
"channel_outbox_list_entries_by_ids_failed",
|
||
resource="channel_outbox",
|
||
error=str(exc),
|
||
)
|
||
raise DependencyError("channel_outbox", Error(str(exc))) from exc
|
||
|
||
async def countOutboxEntries(self, query_filter: OutboxQueryFilter) -> int:
|
||
"""统计符合条件的 outbox 条目数量(仅含 is_deleted=0)。
|
||
|
||
过滤条件与 listOutboxEntries 一致,通过 ``_applyOutboxQueryFilter``
|
||
统一应用,``message_id`` 转换失败时短路返回 0。
|
||
|
||
Raises:
|
||
DependencyError: 数据库故障。
|
||
"""
|
||
try:
|
||
stmt = select(func.count(ChannelOutboxEntryORM.id)).where(ChannelOutboxEntryORM.is_deleted == 0)
|
||
stmt = self._applyOutboxQueryFilter(stmt, query_filter)
|
||
if stmt is None:
|
||
return 0
|
||
result = await self._db.execute(stmt)
|
||
return int(result.scalar() or 0)
|
||
except IntegrityError as exc:
|
||
raise self._translate_db_error(exc, "channel_outbox") from exc
|
||
except SQLAlchemyError as exc:
|
||
raise self._translate_db_error(exc, "channel_outbox") from exc
|
||
except (NotFoundError, ConflictError, DependencyError):
|
||
raise
|
||
except Exception as exc:
|
||
await self._logger.error(
|
||
"channel_outbox_count_entries_failed",
|
||
resource="channel_outbox",
|
||
error=str(exc),
|
||
)
|
||
raise DependencyError("channel_outbox", Error(str(exc))) from exc
|
||
|
||
async def getOutboxStats(
|
||
self,
|
||
channel_type: ChannelType | None = None,
|
||
channel_account_id: str | None = None,
|
||
) -> OutboxStats:
|
||
"""获取 outbox 统计信息(按状态分组,仅含 is_deleted=0)。
|
||
|
||
channel_type / channel_account_id 非空时通过关联 ChannelAccount 表
|
||
过滤(Outbox 表无这些列)。未出现的状态计数为 0。
|
||
|
||
同时返回诊断指标:最近错误 Top5(取自 failed/dead 条目的 ``last_error``)、
|
||
平均投递延迟 ``avg_latency_ms``(基于 ``latency_ms``)与最早 pending 时间。
|
||
|
||
Raises:
|
||
DependencyError: 数据库故障。
|
||
"""
|
||
try:
|
||
|
||
def _apply_channel_filter(stmt: Select[Any]) -> Select[Any]:
|
||
if channel_type is not None or channel_account_id is not None:
|
||
stmt = stmt.join(
|
||
ChannelAccountORM,
|
||
ChannelOutboxEntryORM.account_id == ChannelAccountORM.id,
|
||
)
|
||
if channel_type is not None:
|
||
stmt = stmt.where(ChannelAccountORM.channel_type == channel_type)
|
||
if channel_account_id is not None:
|
||
stmt = stmt.where(ChannelAccountORM.account_id == channel_account_id)
|
||
return stmt
|
||
|
||
status_stmt = (
|
||
select(
|
||
ChannelOutboxEntryORM.status,
|
||
func.count(ChannelOutboxEntryORM.id),
|
||
)
|
||
.where(ChannelOutboxEntryORM.is_deleted == 0)
|
||
.group_by(ChannelOutboxEntryORM.status)
|
||
)
|
||
status_stmt = _apply_channel_filter(status_stmt)
|
||
status_result = await self._db.execute(status_stmt)
|
||
status_counts: dict[str, int] = {
|
||
"pending": 0,
|
||
"sent": 0,
|
||
"suppressed": 0,
|
||
"failed": 0,
|
||
"sent_unconfirmed": 0,
|
||
"dead": 0,
|
||
}
|
||
total = 0
|
||
for status, count in status_result.all():
|
||
if status is not None and status in status_counts:
|
||
status_counts[status] = int(count)
|
||
total += int(count)
|
||
|
||
top_errors_stmt = (
|
||
select(
|
||
ChannelOutboxEntryORM.last_error,
|
||
func.count(ChannelOutboxEntryORM.id),
|
||
)
|
||
.where(
|
||
ChannelOutboxEntryORM.is_deleted == 0,
|
||
ChannelOutboxEntryORM.status.in_(("failed", "dead")),
|
||
ChannelOutboxEntryORM.last_error.is_not(None),
|
||
)
|
||
.group_by(ChannelOutboxEntryORM.last_error)
|
||
.order_by(func.count(ChannelOutboxEntryORM.id).desc())
|
||
.limit(5)
|
||
)
|
||
top_errors_stmt = _apply_channel_filter(top_errors_stmt)
|
||
top_errors_result = await self._db.execute(top_errors_stmt)
|
||
top_errors = tuple(
|
||
{"error": error, "count": int(count)} for error, count in top_errors_result.all() if error is not None
|
||
)
|
||
|
||
avg_latency_stmt = select(func.avg(ChannelOutboxEntryORM.latency_ms)).where(
|
||
ChannelOutboxEntryORM.is_deleted == 0,
|
||
ChannelOutboxEntryORM.latency_ms.is_not(None),
|
||
)
|
||
avg_latency_stmt = _apply_channel_filter(avg_latency_stmt)
|
||
avg_latency_result = await self._db.execute(avg_latency_stmt)
|
||
avg_latency_value = avg_latency_result.scalar()
|
||
avg_latency_ms = float(avg_latency_value) if avg_latency_value is not None else None
|
||
|
||
oldest_pending_stmt = select(func.min(ChannelOutboxEntryORM.created_at)).where(
|
||
ChannelOutboxEntryORM.is_deleted == 0,
|
||
ChannelOutboxEntryORM.status == "pending",
|
||
)
|
||
oldest_pending_stmt = _apply_channel_filter(oldest_pending_stmt)
|
||
oldest_pending_result = await self._db.execute(oldest_pending_stmt)
|
||
oldest_pending_at = oldest_pending_result.scalar()
|
||
|
||
return OutboxStats(
|
||
total=total,
|
||
pending=status_counts["pending"],
|
||
sent=status_counts["sent"],
|
||
suppressed=status_counts["suppressed"],
|
||
failed=status_counts["failed"],
|
||
sent_unconfirmed=status_counts["sent_unconfirmed"],
|
||
dead=status_counts["dead"],
|
||
top_errors=top_errors,
|
||
avg_latency_ms=avg_latency_ms,
|
||
oldest_pending_at=oldest_pending_at,
|
||
)
|
||
except IntegrityError as exc:
|
||
raise self._translate_db_error(exc, "channel_outbox") from exc
|
||
except SQLAlchemyError as exc:
|
||
raise self._translate_db_error(exc, "channel_outbox") from exc
|
||
except (NotFoundError, ConflictError, DependencyError):
|
||
raise
|
||
except Exception as exc:
|
||
await self._logger.error(
|
||
"channel_outbox_get_stats_failed",
|
||
resource="channel_outbox",
|
||
error=str(exc),
|
||
)
|
||
raise DependencyError("channel_outbox", Error(str(exc))) from exc
|
||
|
||
async def exportDeadLetters(
|
||
self,
|
||
query: DeadLetterExportCmd,
|
||
) -> tuple[OutboxEntry, ...]:
|
||
"""死信导出查询(OBX-DL-EXPORT)。
|
||
|
||
按 ``query`` 条件查询 ``DEAD`` 状态发件箱条目,``query.channel_type``
|
||
通过 JOIN ``ChannelAccount`` 表过滤。结果按 ``created_at`` 倒序,
|
||
仅含 ``is_deleted=0`` 条目,最多 ``query.limit`` 条(默认 10000),
|
||
防止大规模死信队列 OOM。``query.format`` 由 dispatch handler 消费,
|
||
本方法不负责格式化。
|
||
|
||
Raises:
|
||
DependencyError: 数据库故障。
|
||
"""
|
||
try:
|
||
stmt = select(ChannelOutboxEntryORM).where(
|
||
ChannelOutboxEntryORM.is_deleted == 0,
|
||
ChannelOutboxEntryORM.status == "dead",
|
||
)
|
||
if query.channel_type is not None:
|
||
stmt = stmt.join(
|
||
ChannelAccountORM,
|
||
ChannelOutboxEntryORM.account_id == ChannelAccountORM.id,
|
||
).where(ChannelAccountORM.channel_type == query.channel_type)
|
||
if query.created_after is not None:
|
||
stmt = stmt.where(ChannelOutboxEntryORM.created_at >= _to_naive_utc(query.created_after))
|
||
if query.created_before is not None:
|
||
stmt = stmt.where(ChannelOutboxEntryORM.created_at <= _to_naive_utc(query.created_before))
|
||
stmt = stmt.order_by(ChannelOutboxEntryORM.created_at.desc()).limit(query.limit)
|
||
result = await self._db.execute(stmt)
|
||
orms = result.scalars().all()
|
||
return tuple(orm_to_outbox_entry(orm) for orm in orms)
|
||
except IntegrityError as exc:
|
||
raise self._translate_db_error(exc, "channel_outbox") from exc
|
||
except SQLAlchemyError as exc:
|
||
raise self._translate_db_error(exc, "channel_outbox") from exc
|
||
except (NotFoundError, ConflictError, DependencyError):
|
||
raise
|
||
except Exception as exc:
|
||
await self._logger.error(
|
||
"channel_outbox_export_dead_letters_failed",
|
||
resource="channel_outbox",
|
||
error=str(exc),
|
||
)
|
||
raise DependencyError("channel_outbox", Error(str(exc))) from exc
|
||
|
||
async def getTrend(
|
||
self,
|
||
query: OutboxTrendQuery,
|
||
) -> tuple[TrendDataPoint, ...]:
|
||
"""投递积压趋势聚合查询(OBX-TREND)。
|
||
|
||
按 ``query.granularity``(minute/hour/day)分桶时间序列,按
|
||
``query.metric``(queue_depth/retry_count/dead_count)聚合对应度量
|
||
指标的时间序列数据点:
|
||
|
||
- ``queue_depth``:各时间桶内新增条目数(按 ``created_at`` 过滤
|
||
与分桶)。
|
||
- ``retry_count``:各时间桶内 ``retry_count`` 总和(按
|
||
``updated_at`` 过滤与分桶)。
|
||
- ``dead_count``:各时间桶内转为 dead 的条目数(按 ``updated_at``
|
||
过滤与分桶)。
|
||
|
||
时间过滤列与分桶列 **必须** 一致,避免"按 ``created_at`` 过滤但按
|
||
``updated_at`` 分桶"导致的漏统计(S3 修复)。
|
||
|
||
``query.channel_type`` / ``query.channel_account_id`` 通过 JOIN
|
||
``ChannelAccount`` 表过滤。结果仅含 ``is_deleted=0`` 条目。
|
||
|
||
Raises:
|
||
DependencyError: 数据库故障。
|
||
"""
|
||
try:
|
||
# DB 时间列为 naive UTC,剥离 DTO 入参的 tzinfo(见 _to_naive_utc 说明)。
|
||
start_naive = _to_naive_utc(query.start_time)
|
||
end_naive = _to_naive_utc(query.end_time)
|
||
# 根据 metric 选择时间列:queue_depth 按 created_at,其余按 updated_at。
|
||
# 时间过滤列与分桶列必须一致,避免漏统计。
|
||
if query.metric == "queue_depth":
|
||
time_col = ChannelOutboxEntryORM.created_at
|
||
count_expr = func.count(ChannelOutboxEntryORM.id)
|
||
elif query.metric == "retry_count":
|
||
time_col = ChannelOutboxEntryORM.updated_at
|
||
count_expr = func.coalesce(func.sum(ChannelOutboxEntryORM.retry_count), 0)
|
||
elif query.metric == "dead_count":
|
||
time_col = ChannelOutboxEntryORM.updated_at
|
||
count_expr = func.count(
|
||
case(
|
||
(ChannelOutboxEntryORM.status == "dead", 1),
|
||
else_=None,
|
||
)
|
||
)
|
||
else:
|
||
raise DependencyError(
|
||
"channel_outbox",
|
||
Error(f"unsupported metric: {query.metric}"),
|
||
)
|
||
bucket_expr = func.date_trunc(query.granularity, time_col)
|
||
stmt = (
|
||
select(
|
||
bucket_expr.label("bucket"),
|
||
count_expr.label("value"),
|
||
)
|
||
.where(
|
||
ChannelOutboxEntryORM.is_deleted == 0,
|
||
time_col >= start_naive,
|
||
time_col < end_naive,
|
||
)
|
||
.group_by(bucket_expr)
|
||
.order_by(bucket_expr)
|
||
)
|
||
if query.channel_type is not None or query.channel_account_id is not None:
|
||
stmt = stmt.join(
|
||
ChannelAccountORM,
|
||
ChannelOutboxEntryORM.account_id == ChannelAccountORM.id,
|
||
)
|
||
if query.channel_type is not None:
|
||
stmt = stmt.where(ChannelAccountORM.channel_type == query.channel_type)
|
||
if query.channel_account_id is not None:
|
||
stmt = stmt.where(ChannelAccountORM.account_id == query.channel_account_id)
|
||
result = await self._db.execute(stmt)
|
||
return tuple(
|
||
TrendDataPoint(
|
||
timestamp=bucket,
|
||
value=int(value or 0),
|
||
)
|
||
for bucket, value in result.all()
|
||
)
|
||
except IntegrityError as exc:
|
||
raise self._translate_db_error(exc, "channel_outbox") from exc
|
||
except SQLAlchemyError as exc:
|
||
raise self._translate_db_error(exc, "channel_outbox") from exc
|
||
except (NotFoundError, ConflictError, DependencyError):
|
||
raise
|
||
except Exception as exc:
|
||
await self._logger.error(
|
||
"channel_outbox_get_trend_failed",
|
||
resource="channel_outbox",
|
||
error=str(exc),
|
||
)
|
||
raise DependencyError("channel_outbox", Error(str(exc))) from exc
|
||
|
||
async def getAccountStats(
|
||
self,
|
||
channel_type: ChannelType | None = None,
|
||
) -> AccountStats:
|
||
"""实现 ChannelAccountRepositoryPort.getAccountStats。
|
||
|
||
通过 GROUP BY channel_type, status 单次查询完成聚合。
|
||
"""
|
||
try:
|
||
stmt = (
|
||
select(
|
||
ChannelAccountORM.channel_type,
|
||
ChannelAccountORM.status,
|
||
func.count().label("count"),
|
||
)
|
||
.where(ChannelAccountORM.is_deleted == 0)
|
||
.group_by(ChannelAccountORM.channel_type, ChannelAccountORM.status)
|
||
)
|
||
if channel_type is not None:
|
||
stmt = stmt.where(ChannelAccountORM.channel_type == channel_type)
|
||
result = await self._db.execute(stmt)
|
||
|
||
by_channel: dict[str, int] = {}
|
||
by_status: dict[str, int] = {}
|
||
total = 0
|
||
for row in result.all():
|
||
ch = row.channel_type
|
||
st = row.status
|
||
cnt = int(row.count)
|
||
by_channel[ch] = by_channel.get(ch, 0) + cnt
|
||
by_status[st] = by_status.get(st, 0) + cnt
|
||
total += cnt
|
||
return AccountStats(total=total, by_channel=by_channel, by_status=by_status)
|
||
except IntegrityError as exc:
|
||
raise self._translate_db_error(exc, "channel_account") from exc
|
||
except SQLAlchemyError as exc:
|
||
raise self._translate_db_error(exc, "channel_account") from exc
|
||
except (NotFoundError, ConflictError, DependencyError):
|
||
raise
|
||
except Exception as exc:
|
||
await self._logger.error(
|
||
"channel_account_get_account_stats_failed",
|
||
resource="channel_account",
|
||
error=str(exc),
|
||
)
|
||
raise DependencyError("channel_account", Error(str(exc))) from exc
|
||
|
||
async def getSessionStats(
|
||
self,
|
||
channel_type: ChannelType | None = None,
|
||
) -> SessionStats:
|
||
"""实现 ChannelSessionRepositoryPort.getSessionStats。
|
||
|
||
通过 GROUP BY channel_type, is_temporary 单次查询完成聚合。
|
||
"""
|
||
try:
|
||
stmt = (
|
||
select(
|
||
ChannelSessionORM.channel_type,
|
||
ChannelSessionORM.is_temporary,
|
||
func.count().label("count"),
|
||
)
|
||
.where(ChannelSessionORM.is_deleted == 0)
|
||
.group_by(ChannelSessionORM.channel_type, ChannelSessionORM.is_temporary)
|
||
)
|
||
if channel_type is not None:
|
||
stmt = stmt.where(ChannelSessionORM.channel_type == channel_type)
|
||
result = await self._db.execute(stmt)
|
||
|
||
by_channel: dict[str, int] = {}
|
||
by_is_temporary: dict[str, int] = {"true": 0, "false": 0}
|
||
total = 0
|
||
for row in result.all():
|
||
ch = row.channel_type
|
||
is_tmp = "true" if row.is_temporary else "false"
|
||
cnt = int(row.count)
|
||
by_channel[ch] = by_channel.get(ch, 0) + cnt
|
||
by_is_temporary[is_tmp] += cnt
|
||
total += cnt
|
||
return SessionStats(total=total, by_channel=by_channel, by_is_temporary=by_is_temporary)
|
||
except IntegrityError as exc:
|
||
raise self._translate_db_error(exc, "channel_session") from exc
|
||
except SQLAlchemyError as exc:
|
||
raise self._translate_db_error(exc, "channel_session") from exc
|
||
except (NotFoundError, ConflictError, DependencyError):
|
||
raise
|
||
except Exception as exc:
|
||
await self._logger.error(
|
||
"channel_session_get_session_stats_failed",
|
||
resource="channel_session",
|
||
error=str(exc),
|
||
)
|
||
raise DependencyError("channel_session", Error(str(exc))) from exc
|
||
|
||
async def deleteOutboxEntry(
|
||
self,
|
||
outbox_id: str,
|
||
tx: TransactionContext | None = None,
|
||
) -> bool:
|
||
"""逻辑删除 outbox 条目(置 is_deleted=1 / deleted_at=now())。
|
||
|
||
条目不存在返回 False(不抛 NotFoundError,由应用层校验状态机)。
|
||
保留审计痕迹(INV-9)。
|
||
|
||
Args:
|
||
outbox_id: 发件箱条目 ID。
|
||
tx: 事务上下文,非空时加入应用层事务,**不得** 自主提交。
|
||
为 ``None`` 时按单方法提交(向后兼容)。
|
||
|
||
Raises:
|
||
DependencyError: 数据库故障。
|
||
"""
|
||
commit = self._should_commit(tx)
|
||
try:
|
||
stmt = select(ChannelOutboxEntryORM).where(
|
||
ChannelOutboxEntryORM.outbox_id == outbox_id,
|
||
ChannelOutboxEntryORM.is_deleted == 0,
|
||
)
|
||
orm = await self._db.scalar(stmt)
|
||
if orm is None:
|
||
return False
|
||
orm.is_deleted = 1
|
||
orm.deleted_at = utc_now_naive()
|
||
orm.updated_at = utc_now_naive()
|
||
if commit:
|
||
await self._db.commit()
|
||
return True
|
||
except IntegrityError as exc:
|
||
raise self._translate_db_error(exc, "channel_outbox") from exc
|
||
except SQLAlchemyError as exc:
|
||
raise self._translate_db_error(exc, "channel_outbox") from exc
|
||
except (NotFoundError, ConflictError, DependencyError):
|
||
raise
|
||
except Exception as exc:
|
||
await self._logger.error(
|
||
"channel_outbox_delete_entry_failed",
|
||
resource="channel_outbox",
|
||
error=str(exc),
|
||
)
|
||
raise DependencyError("channel_outbox", Error(str(exc))) from exc
|
||
|
||
async def cleanupOldDeadEntries(self, before: datetime, limit: int) -> tuple[str, ...]:
|
||
"""物理删除早于指定时间的 DEAD 状态发件箱条目(FR-22 死信清理)。
|
||
|
||
执行物理删除(非软删除),条件为 ``status == "dead"`` AND
|
||
``updated_at < before`` AND ``is_deleted == 0``,通过子查询限制删除
|
||
数量。先 SELECT 被删除条目的 ``outbox_id`` 列表再执行 DELETE,
|
||
返回被删除条目的业务 ID 列表,供调度器发布携带完整 ``entry_ids``
|
||
的 ``OutboxEntryPurgedEvent``。
|
||
|
||
Raises:
|
||
DependencyError: 数据库故障。
|
||
"""
|
||
try:
|
||
# DB 时间列为 naive UTC,剥离入参的 tzinfo(见 _to_naive_utc 说明)。
|
||
before_naive = _to_naive_utc(before)
|
||
select_stmt = (
|
||
select(ChannelOutboxEntryORM.outbox_id)
|
||
.where(
|
||
ChannelOutboxEntryORM.status == "dead",
|
||
ChannelOutboxEntryORM.updated_at < before_naive,
|
||
ChannelOutboxEntryORM.is_deleted == 0,
|
||
)
|
||
.limit(limit)
|
||
)
|
||
result = await self._db.execute(select_stmt)
|
||
outbox_ids = [row[0] for row in result.fetchall()]
|
||
if not outbox_ids:
|
||
return ()
|
||
await self._db.execute(delete(ChannelOutboxEntryORM).where(ChannelOutboxEntryORM.outbox_id.in_(outbox_ids)))
|
||
await self._db.commit()
|
||
return tuple(outbox_ids)
|
||
except IntegrityError as exc:
|
||
raise self._translate_db_error(exc, "channel_outbox") from exc
|
||
except SQLAlchemyError as exc:
|
||
raise self._translate_db_error(exc, "channel_outbox") from exc
|
||
except (NotFoundError, ConflictError, DependencyError):
|
||
# 契约层错误:直接放行,不二次翻译,保留原始异常链与错误码
|
||
raise
|
||
except Exception as exc:
|
||
# 兜底:防止非契约异常穿透至核心层(INV-7);保留异常链以便追踪
|
||
await self._logger.error(
|
||
"channel_outbox_cleanup_old_dead_entries_failed",
|
||
resource="channel_outbox",
|
||
error=str(exc),
|
||
)
|
||
raise DependencyError("channel_outbox", Error(str(exc))) from exc
|
||
|
||
async def cleanupOldSentEntries(self, before: datetime, limit: int) -> tuple[str, ...]:
|
||
"""物理删除早于指定时间的 SENT 状态发件箱条目(FR-22 已投递清理)。
|
||
|
||
执行物理删除(非软删除),条件为 ``status == "sent"`` AND
|
||
``updated_at < before`` AND ``is_deleted == 0``,通过子查询限制删除
|
||
数量。先 SELECT 被删除条目的 ``outbox_id`` 列表再执行 DELETE,
|
||
返回被删除条目的业务 ID 列表,供调度器发布携带完整 ``entry_ids``
|
||
的 ``OutboxEntryPurgedEvent``。
|
||
|
||
Raises:
|
||
DependencyError: 数据库故障。
|
||
"""
|
||
try:
|
||
# DB 时间列为 naive UTC,剥离入参的 tzinfo(见 _to_naive_utc 说明)。
|
||
before_naive = _to_naive_utc(before)
|
||
select_stmt = (
|
||
select(ChannelOutboxEntryORM.outbox_id)
|
||
.where(
|
||
ChannelOutboxEntryORM.status == "sent",
|
||
ChannelOutboxEntryORM.updated_at < before_naive,
|
||
ChannelOutboxEntryORM.is_deleted == 0,
|
||
)
|
||
.limit(limit)
|
||
)
|
||
result = await self._db.execute(select_stmt)
|
||
outbox_ids = [row[0] for row in result.fetchall()]
|
||
if not outbox_ids:
|
||
return ()
|
||
await self._db.execute(delete(ChannelOutboxEntryORM).where(ChannelOutboxEntryORM.outbox_id.in_(outbox_ids)))
|
||
await self._db.commit()
|
||
return tuple(outbox_ids)
|
||
except IntegrityError as exc:
|
||
raise self._translate_db_error(exc, "channel_outbox") from exc
|
||
except SQLAlchemyError as exc:
|
||
raise self._translate_db_error(exc, "channel_outbox") from exc
|
||
except (NotFoundError, ConflictError, DependencyError):
|
||
# 契约层错误:直接放行,不二次翻译,保留原始异常链与错误码
|
||
raise
|
||
except Exception as exc:
|
||
# 兜底:防止非契约异常穿透至核心层(INV-7);保留异常链以便追踪
|
||
await self._logger.error(
|
||
"channel_outbox_cleanup_old_sent_entries_failed",
|
||
resource="channel_outbox",
|
||
error=str(exc),
|
||
)
|
||
raise DependencyError("channel_outbox", Error(str(exc))) from exc
|
||
|
||
# ═══════════════════════════════════════════════════════════════════════════
|
||
# 用户身份 CRUD
|
||
# ═══════════════════════════════════════════════════════════════════════════
|
||
|
||
async def saveUserIdentity(
|
||
self,
|
||
cmd: SaveUserIdentityCmd,
|
||
tx: TransactionContext | None = None,
|
||
) -> UserIdentity:
|
||
"""保存用户身份,返回含 identity_id 与时间戳的 UserIdentity。
|
||
|
||
唯一约束冲突抛 ``ConflictError``。
|
||
|
||
Args:
|
||
cmd: 保存用户身份命令。
|
||
tx: 事务上下文,非空时加入应用层事务,**不得** 自主提交。
|
||
|
||
Raises:
|
||
ConflictError: 唯一约束冲突。
|
||
DependencyError: 数据库故障。
|
||
"""
|
||
commit = self._should_commit(tx)
|
||
data = user_identity_for_write(
|
||
{
|
||
"identity_id": uuid.uuid4().hex,
|
||
"identity_type": cmd.identity_type,
|
||
"identity_value": cmd.identity_value,
|
||
"channel_type": cmd.channel_type if cmd.channel_type is not None else None,
|
||
"channel_sender_id": cmd.channel_sender_id,
|
||
"source": cmd.source,
|
||
"user_id": int(cmd.user_id) if cmd.user_id else None,
|
||
}
|
||
)
|
||
try:
|
||
orm = await self._repos.identity.create(data, commit=commit)
|
||
return orm_to_user_identity(orm)
|
||
except IntegrityError as exc:
|
||
raise self._translate_db_error(exc, "user_identity") from exc
|
||
except SQLAlchemyError as exc:
|
||
raise self._translate_db_error(exc, "user_identity") from exc
|
||
except (NotFoundError, ConflictError, DependencyError):
|
||
# 契约层错误:mappers 翻译的 DependencyError 与本适配器抛出的 NotFoundError/ConflictError
|
||
# 直接放行,不二次翻译,保留原始异常链与错误码
|
||
raise
|
||
except Exception as exc:
|
||
# 兜底:mappers 调用过程中可能抛出 TypeError 等非契约异常,翻译为 DependencyError
|
||
# 防止穿透至核心层(INV-7);保留异常链以便追踪
|
||
await self._logger.error(
|
||
"user_identity_save_failed",
|
||
resource="user_identity",
|
||
error=str(exc),
|
||
)
|
||
raise DependencyError("user_identity", Error(str(exc))) from exc
|
||
|
||
async def getUserIdentity(self, identity_type: str, identity_value: str) -> UserIdentity | None:
|
||
"""按身份类型与身份值查询用户身份;不存在返回 None。
|
||
|
||
Raises:
|
||
DependencyError: 数据库故障。
|
||
"""
|
||
try:
|
||
orm = await self._repos.identity.get_by_type_and_value(identity_type, identity_value)
|
||
if orm is None:
|
||
return None
|
||
return orm_to_user_identity(orm)
|
||
except IntegrityError as exc:
|
||
raise self._translate_db_error(exc, "user_identity") from exc
|
||
except SQLAlchemyError as exc:
|
||
raise self._translate_db_error(exc, "user_identity") from exc
|
||
except (NotFoundError, ConflictError, DependencyError):
|
||
# 契约层错误:mappers 翻译的 DependencyError 与本适配器抛出的 NotFoundError/ConflictError
|
||
# 直接放行,不二次翻译,保留原始异常链与错误码
|
||
raise
|
||
except Exception as exc:
|
||
# 兜底:mappers 调用过程中可能抛出 TypeError 等非契约异常,翻译为 DependencyError
|
||
# 防止穿透至核心层(INV-7);保留异常链以便追踪
|
||
await self._logger.error(
|
||
"user_identity_get_failed",
|
||
resource="user_identity",
|
||
error=str(exc),
|
||
)
|
||
raise DependencyError("user_identity", Error(str(exc))) from exc
|
||
|
||
async def getUserIdentityByIdentityId(self, identity_id: str) -> UserIdentity | None:
|
||
"""按统一身份 ID 查询用户身份(P3 渐进式绑定);不存在返回 None。
|
||
|
||
Raises:
|
||
DependencyError: 数据库故障。
|
||
"""
|
||
try:
|
||
orm = await self._repos.identity.get_by_identity_id(identity_id)
|
||
if orm is None:
|
||
return None
|
||
return orm_to_user_identity(orm)
|
||
except IntegrityError as exc:
|
||
raise self._translate_db_error(exc, "user_identity") from exc
|
||
except SQLAlchemyError as exc:
|
||
raise self._translate_db_error(exc, "user_identity") from exc
|
||
except (NotFoundError, ConflictError, DependencyError):
|
||
# 契约层错误:直接放行,保留原始异常链与错误码
|
||
raise
|
||
except Exception as exc:
|
||
# 兜底:防止非契约异常穿透至核心层(INV-7);保留异常链以便追踪
|
||
await self._logger.error(
|
||
"user_identity_get_by_identity_id_failed",
|
||
resource="user_identity",
|
||
error=str(exc),
|
||
)
|
||
raise DependencyError("user_identity", Error(str(exc))) from exc
|
||
|
||
async def updateUserIdentity(
|
||
self,
|
||
identity: UserIdentity,
|
||
*,
|
||
tx: TransactionContext | None = None,
|
||
) -> UserIdentity:
|
||
"""更新用户身份(P3 渐进式绑定),返回更新后的 UserIdentity。
|
||
|
||
通过 ``WHERE version = old_version`` 实现乐观锁,版本不匹配
|
||
(并发修改或记录不存在)抛 ``ConflictError``。
|
||
|
||
聚合根的 ``bindUser`` / ``unbindUser`` 已在内存中递增 ``version``,
|
||
本方法以 ``identity.version - 1`` 作为期望的旧版本号,以
|
||
``identity.version`` 作为新版本号持久化。
|
||
|
||
Args:
|
||
identity: 待更新的用户身份值对象(已通过 ``bindUser`` /
|
||
``unbindUser`` 修改状态并递增版本号)。
|
||
tx: 事务上下文,非空时加入应用层事务,**不得** 自主提交。
|
||
|
||
Raises:
|
||
ConflictError: 乐观锁版本不匹配。
|
||
DependencyError: 数据库故障。
|
||
"""
|
||
commit = self._should_commit(tx)
|
||
# 聚合根已递增 version,旧版本号 = 当前 version - 1
|
||
expected_version = identity.version - 1
|
||
updates = {
|
||
"user_id": int(identity.user_id) if identity.user_id else None,
|
||
"identity_type": identity.identity_type,
|
||
}
|
||
try:
|
||
orm = await self._repos.identity.update_with_optimistic_lock(
|
||
identity_id=identity.identity_id,
|
||
expected_version=expected_version,
|
||
updates=updates,
|
||
commit=commit,
|
||
)
|
||
if orm is None:
|
||
raise ConflictError("user_identity")
|
||
return orm_to_user_identity(orm)
|
||
except IntegrityError as exc:
|
||
raise self._translate_db_error(exc, "user_identity") from exc
|
||
except SQLAlchemyError as exc:
|
||
raise self._translate_db_error(exc, "user_identity") from exc
|
||
except (NotFoundError, ConflictError, DependencyError):
|
||
# 契约层错误:直接放行,保留原始异常链与错误码
|
||
raise
|
||
except Exception as exc:
|
||
# 兜底:防止非契约异常穿透至核心层(INV-7);保留异常链以便追踪
|
||
await self._logger.error(
|
||
"user_identity_update_failed",
|
||
resource="user_identity",
|
||
error=str(exc),
|
||
)
|
||
raise DependencyError("user_identity", Error(str(exc))) from exc
|
||
|
||
# ═══════════════════════════════════════════════════════════════════════════
|
||
# 幂等记录(FR-19)
|
||
# ═══════════════════════════════════════════════════════════════════════════
|
||
|
||
async def getIdempotencyRecord(self, idempotency_key: str) -> IdempotencyRecord | None:
|
||
"""按幂等键查询记录。
|
||
|
||
Raises:
|
||
DependencyError: 数据库故障。
|
||
"""
|
||
try:
|
||
orm = await self._repos.idempotency.get_by_key(idempotency_key)
|
||
if orm is None:
|
||
return None
|
||
return IdempotencyRecord(
|
||
record_id=orm.id,
|
||
idempotency_key=orm.idempotency_key,
|
||
operation=orm.operation,
|
||
status=orm.status,
|
||
response_body=orm.response_body,
|
||
in_progress_started_at=orm.in_progress_started_at,
|
||
)
|
||
except IntegrityError as exc:
|
||
raise self._translate_db_error(exc, "channel_idempotency") from exc
|
||
except SQLAlchemyError as exc:
|
||
raise self._translate_db_error(exc, "channel_idempotency") from exc
|
||
|
||
async def createIdempotencyRecord(
|
||
self,
|
||
idempotency_key: str,
|
||
operation: str,
|
||
created_by: str | None = None,
|
||
) -> IdempotencyRecord:
|
||
"""创建 in_progress 幂等记录,首次写入抢占处理权。
|
||
|
||
Raises:
|
||
ConflictError: 幂等键已存在(重复请求)。
|
||
DependencyError: 数据库故障。
|
||
"""
|
||
try:
|
||
orm = await self._repos.idempotency.create(
|
||
{
|
||
"idempotency_key": idempotency_key,
|
||
"operation": operation,
|
||
"created_by": created_by,
|
||
}
|
||
)
|
||
return IdempotencyRecord(
|
||
record_id=orm.id,
|
||
idempotency_key=orm.idempotency_key,
|
||
operation=orm.operation,
|
||
status=orm.status,
|
||
response_body=orm.response_body,
|
||
in_progress_started_at=orm.in_progress_started_at,
|
||
)
|
||
except IntegrityError as exc:
|
||
raise self._translate_db_error(exc, "channel_idempotency") from exc
|
||
except SQLAlchemyError as exc:
|
||
raise self._translate_db_error(exc, "channel_idempotency") from exc
|
||
|
||
async def updateIdempotencyStatus(
|
||
self,
|
||
record_id: int,
|
||
status: str,
|
||
response_body: dict[str, Any] | None = None,
|
||
) -> bool:
|
||
"""更新幂等记录状态。
|
||
|
||
Raises:
|
||
DependencyError: 数据库故障。
|
||
"""
|
||
try:
|
||
rowcount = await self._repos.idempotency.update_status(record_id, status, response_body=response_body)
|
||
return rowcount > 0
|
||
except IntegrityError as exc:
|
||
raise self._translate_db_error(exc, "channel_idempotency") from exc
|
||
except SQLAlchemyError as exc:
|
||
raise self._translate_db_error(exc, "channel_idempotency") from exc
|
||
|
||
async def deleteIdempotencyRecord(self, idempotency_key: str) -> bool:
|
||
"""按幂等键物理删除记录,允许 failed 记录重试。
|
||
|
||
Raises:
|
||
DependencyError: 数据库故障。
|
||
"""
|
||
try:
|
||
rowcount = await self._repos.idempotency.delete_by_key(idempotency_key)
|
||
return rowcount > 0
|
||
except IntegrityError as exc:
|
||
raise self._translate_db_error(exc, "channel_idempotency") from exc
|
||
except SQLAlchemyError as exc:
|
||
raise self._translate_db_error(exc, "channel_idempotency") from exc
|
||
|
||
# ═══════════════════════════════════════════════════════════════════════════
|
||
# 健康检查与诊断(FR-35)
|
||
# ═══════════════════════════════════════════════════════════════════════════
|
||
|
||
async def ping(self) -> bool:
|
||
"""检测数据库是否可用。
|
||
|
||
执行 ``SELECT 1`` 验证数据库连接可用性。数据库不可用时返回 False,
|
||
不抛异常(供 ``HealthAggregator.checkHealth`` 判断数据库状态)。
|
||
|
||
``SQLAlchemyError`` 故障 **不得** 静默吞噬。先记录 ``WARN`` 级别
|
||
日志(含异常摘要)再返回 ``False``,供 ``HealthAggregator`` 判断
|
||
数据库状态,根因可通过日志追溯。
|
||
"""
|
||
try:
|
||
await self._db.execute(text("SELECT 1"))
|
||
return True
|
||
except SQLAlchemyError as exc:
|
||
await self._logger.warn(
|
||
"db_ping_failed",
|
||
resource="db",
|
||
error=str(exc),
|
||
)
|
||
return False
|
||
|
||
async def getConnectionPoolStatus(self) -> ConnectionPoolStatus:
|
||
"""获取数据库连接池状态。
|
||
|
||
返回 ``ConnectionPoolStatus``,包含连接池的关键指标,供
|
||
``DiagnosticsExporter`` 填充诊断包。
|
||
|
||
Raises:
|
||
DependencyError: 数据库故障。
|
||
"""
|
||
engine = self._db.bind
|
||
if engine is None:
|
||
return ConnectionPoolStatus(available=False, reason="engine not bound")
|
||
pool = engine.pool
|
||
return ConnectionPoolStatus(
|
||
available=True,
|
||
size=pool.size(),
|
||
checked_in=pool.checkedin(),
|
||
checked_out=pool.checkedout(),
|
||
overflow=pool.overflow(),
|
||
status=pool.status(),
|
||
)
|
||
|
||
# ═══════════════════════════════════════════════════════════════════════════
|
||
# 聚合视图域 analytics 子域深度分析(ANL-01/02/04/05/06/07)
|
||
# ═══════════════════════════════════════════════════════════════════════════
|
||
|
||
async def analyzeMessages(
|
||
self,
|
||
*,
|
||
channel_type: ChannelType | None,
|
||
start_time: datetime,
|
||
end_time: datetime,
|
||
granularity: str,
|
||
) -> MessageAnalytics:
|
||
"""消息深度分析聚合查询(ANL-01)。
|
||
|
||
按 ``granularity`` 分桶时间序列,按渠道、角色、投递状态多维分组计数。
|
||
Message 表无 ``channel_type`` 列与 ``is_deleted`` 列,渠道维度通过
|
||
JOIN ``conversations`` 表(含 ``channel_type``)获取。
|
||
|
||
Raises:
|
||
DependencyError: 数据库故障。
|
||
"""
|
||
try:
|
||
# DB 时间列为 naive UTC,剥离 DTO 入参的 tzinfo(见 _to_naive_utc 说明)。
|
||
start_naive = _to_naive_utc(start_time)
|
||
end_naive = _to_naive_utc(end_time)
|
||
# 总数查询
|
||
total_stmt = select(func.count(MessageORM.id)).where(
|
||
MessageORM.created_at >= start_naive,
|
||
MessageORM.created_at < end_naive,
|
||
)
|
||
if channel_type is not None:
|
||
total_stmt = total_stmt.join(
|
||
ConversationORM,
|
||
ConversationORM.id == MessageORM.conversation_id,
|
||
).where(ConversationORM.channel_type == channel_type)
|
||
total = int(await self._db.scalar(total_stmt) or 0)
|
||
|
||
# 时间序列
|
||
bucket_expr = func.date_trunc(granularity, MessageORM.created_at)
|
||
ts_stmt = (
|
||
select(bucket_expr, func.count(MessageORM.id))
|
||
.where(
|
||
MessageORM.created_at >= start_naive,
|
||
MessageORM.created_at < end_naive,
|
||
)
|
||
.group_by(bucket_expr)
|
||
.order_by(bucket_expr)
|
||
)
|
||
if channel_type is not None:
|
||
ts_stmt = ts_stmt.join(
|
||
ConversationORM,
|
||
ConversationORM.id == MessageORM.conversation_id,
|
||
).where(ConversationORM.channel_type == channel_type)
|
||
ts_result = await self._db.execute(ts_stmt)
|
||
timeseries = tuple(
|
||
TimeSeriesPoint(
|
||
timestamp=format_utc_datetime(bucket) or "",
|
||
value=int(count),
|
||
)
|
||
for bucket, count in ts_result.all()
|
||
)
|
||
|
||
# 按渠道、角色、投递状态联合分组(单次查询)
|
||
combined_stmt = (
|
||
select(
|
||
ConversationORM.channel_type,
|
||
MessageORM.role,
|
||
MessageORM.delivery_status,
|
||
func.count(MessageORM.id).label("count"),
|
||
)
|
||
.join(
|
||
ConversationORM,
|
||
ConversationORM.id == MessageORM.conversation_id,
|
||
)
|
||
.where(
|
||
MessageORM.created_at >= start_naive,
|
||
MessageORM.created_at < end_naive,
|
||
)
|
||
.group_by(
|
||
ConversationORM.channel_type,
|
||
MessageORM.role,
|
||
MessageORM.delivery_status,
|
||
)
|
||
)
|
||
if channel_type is not None:
|
||
combined_stmt = combined_stmt.where(ConversationORM.channel_type == channel_type)
|
||
combined_result = await self._db.execute(combined_stmt)
|
||
by_channel: dict[str, int] = {}
|
||
by_role: dict[str, int] = {}
|
||
by_delivery_status: dict[str, int] = {}
|
||
for ch, role, status, count in combined_result.all():
|
||
cnt = int(count)
|
||
if ch is not None:
|
||
by_channel[ch] = by_channel.get(ch, 0) + cnt
|
||
if role is not None:
|
||
by_role[role] = by_role.get(role, 0) + cnt
|
||
if status is not None:
|
||
by_delivery_status[status] = by_delivery_status.get(status, 0) + cnt
|
||
|
||
return MessageAnalytics(
|
||
total=total,
|
||
timeseries=timeseries,
|
||
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:
|
||
await self._logger.error(
|
||
"message_analyze_failed",
|
||
resource="message",
|
||
error=str(exc),
|
||
)
|
||
raise DependencyError("message", Error(str(exc))) from exc
|
||
|
||
async def getMessageDistribution(
|
||
self,
|
||
*,
|
||
channel_type: ChannelType | None,
|
||
start_time: datetime,
|
||
end_time: datetime,
|
||
) -> MessageDistribution:
|
||
"""消息类型分布聚合查询(ANL-02)。
|
||
|
||
按消息类型分组计数并计算百分比。Message 表无 ``is_deleted`` 列,
|
||
不做软删除过滤。
|
||
|
||
Raises:
|
||
DependencyError: 数据库故障。
|
||
"""
|
||
try:
|
||
# DB 时间列为 naive UTC,剥离 DTO 入参的 tzinfo(见 _to_naive_utc 说明)。
|
||
start_naive = _to_naive_utc(start_time)
|
||
end_naive = _to_naive_utc(end_time)
|
||
# 总数查询
|
||
total_stmt = select(func.count(MessageORM.id)).where(
|
||
MessageORM.created_at >= start_naive,
|
||
MessageORM.created_at < end_naive,
|
||
)
|
||
if channel_type is not None:
|
||
total_stmt = total_stmt.join(
|
||
ConversationORM,
|
||
ConversationORM.id == MessageORM.conversation_id,
|
||
).where(ConversationORM.channel_type == channel_type)
|
||
total = int(await self._db.scalar(total_stmt) or 0)
|
||
|
||
# 按消息类型分组
|
||
type_stmt = (
|
||
select(MessageORM.message_type, func.count(MessageORM.id))
|
||
.where(
|
||
MessageORM.created_at >= start_naive,
|
||
MessageORM.created_at < end_naive,
|
||
)
|
||
.group_by(MessageORM.message_type)
|
||
)
|
||
if channel_type is not None:
|
||
type_stmt = type_stmt.join(
|
||
ConversationORM,
|
||
ConversationORM.id == MessageORM.conversation_id,
|
||
).where(ConversationORM.channel_type == channel_type)
|
||
type_result = await self._db.execute(type_stmt)
|
||
by_type: dict[str, int] = {}
|
||
for msg_type, count in type_result.all():
|
||
if msg_type is not None:
|
||
by_type[msg_type] = int(count)
|
||
|
||
by_type_percent = {k: (v / total * 100) if total > 0 else 0.0 for k, v in by_type.items()}
|
||
return MessageDistribution(
|
||
total=total,
|
||
by_type=by_type,
|
||
by_type_percent=by_type_percent,
|
||
)
|
||
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:
|
||
await self._logger.error(
|
||
"message_get_distribution_failed",
|
||
resource="message",
|
||
error=str(exc),
|
||
)
|
||
raise DependencyError("message", Error(str(exc))) from exc
|
||
|
||
async def analyzeSessions(
|
||
self,
|
||
*,
|
||
channel_type: ChannelType | None,
|
||
start_time: datetime,
|
||
end_time: datetime,
|
||
granularity: str,
|
||
) -> SessionAnalytics:
|
||
"""会话分析聚合查询(ANL-04)。
|
||
|
||
基于 ``last_message_at`` 字段过滤时间范围,按时间粒度、渠道、临时性、
|
||
消息数区间多维分组。消息数通过子查询统计各会话关联 ``conversation``
|
||
的消息总数后分桶。
|
||
|
||
Raises:
|
||
DependencyError: 数据库故障。
|
||
"""
|
||
try:
|
||
# DB 时间列为 naive UTC,剥离 DTO 入参的 tzinfo(见 _to_naive_utc 说明)。
|
||
start_naive = _to_naive_utc(start_time)
|
||
end_naive = _to_naive_utc(end_time)
|
||
# 总数查询
|
||
total_stmt = select(func.count(ChannelSessionORM.id)).where(
|
||
ChannelSessionORM.last_message_at >= start_naive,
|
||
ChannelSessionORM.last_message_at < end_naive,
|
||
ChannelSessionORM.is_deleted == 0,
|
||
)
|
||
if channel_type is not None:
|
||
total_stmt = total_stmt.where(ChannelSessionORM.channel_type == channel_type)
|
||
total = int(await self._db.scalar(total_stmt) or 0)
|
||
|
||
# 时间序列
|
||
bucket_expr = func.date_trunc(granularity, ChannelSessionORM.last_message_at)
|
||
ts_stmt = (
|
||
select(bucket_expr, func.count(ChannelSessionORM.id))
|
||
.where(
|
||
ChannelSessionORM.last_message_at >= start_naive,
|
||
ChannelSessionORM.last_message_at < end_naive,
|
||
ChannelSessionORM.is_deleted == 0,
|
||
)
|
||
.group_by(bucket_expr)
|
||
.order_by(bucket_expr)
|
||
)
|
||
if channel_type is not None:
|
||
ts_stmt = ts_stmt.where(ChannelSessionORM.channel_type == channel_type)
|
||
ts_result = await self._db.execute(ts_stmt)
|
||
timeseries = tuple(
|
||
TimeSeriesPoint(
|
||
timestamp=format_utc_datetime(bucket) or "",
|
||
value=int(count),
|
||
)
|
||
for bucket, count in ts_result.all()
|
||
)
|
||
|
||
# 按渠道、临时性联合分组
|
||
combined_stmt = (
|
||
select(
|
||
ChannelSessionORM.channel_type,
|
||
ChannelSessionORM.is_temporary,
|
||
func.count(ChannelSessionORM.id),
|
||
)
|
||
.where(
|
||
ChannelSessionORM.last_message_at >= start_naive,
|
||
ChannelSessionORM.last_message_at < end_naive,
|
||
ChannelSessionORM.is_deleted == 0,
|
||
)
|
||
.group_by(
|
||
ChannelSessionORM.channel_type,
|
||
ChannelSessionORM.is_temporary,
|
||
)
|
||
)
|
||
if channel_type is not None:
|
||
combined_stmt = combined_stmt.where(ChannelSessionORM.channel_type == channel_type)
|
||
combined_result = await self._db.execute(combined_stmt)
|
||
by_channel: dict[str, int] = {}
|
||
by_is_temporary: dict[str, int] = {"true": 0, "false": 0}
|
||
for ch, is_tmp, count in combined_result.all():
|
||
cnt = int(count)
|
||
if ch is not None:
|
||
by_channel[ch] = by_channel.get(ch, 0) + cnt
|
||
key = "true" if is_tmp else "false"
|
||
by_is_temporary[key] += cnt
|
||
|
||
# 按消息数分桶(子查询统计各 conversation 消息总数)
|
||
msg_count_subq = (
|
||
select(
|
||
MessageORM.conversation_id.label("conv_id"),
|
||
func.count(MessageORM.id).label("msg_count"),
|
||
)
|
||
.group_by(MessageORM.conversation_id)
|
||
.subquery()
|
||
)
|
||
mc_bucket = case(
|
||
(msg_count_subq.c.msg_count.is_(None), "0-10"),
|
||
(msg_count_subq.c.msg_count <= 10, "0-10"),
|
||
(msg_count_subq.c.msg_count <= 50, "11-50"),
|
||
(msg_count_subq.c.msg_count <= 100, "51-100"),
|
||
else_="100+",
|
||
)
|
||
mc_stmt = (
|
||
select(mc_bucket, func.count(ChannelSessionORM.id))
|
||
.select_from(ChannelSessionORM)
|
||
.outerjoin(
|
||
msg_count_subq,
|
||
msg_count_subq.c.conv_id == ChannelSessionORM.conversation_id,
|
||
)
|
||
.where(
|
||
ChannelSessionORM.last_message_at >= start_naive,
|
||
ChannelSessionORM.last_message_at < end_naive,
|
||
ChannelSessionORM.is_deleted == 0,
|
||
)
|
||
.group_by(mc_bucket)
|
||
)
|
||
if channel_type is not None:
|
||
mc_stmt = mc_stmt.where(ChannelSessionORM.channel_type == channel_type)
|
||
mc_result = await self._db.execute(mc_stmt)
|
||
by_message_count_bucket: dict[str, int] = {
|
||
"0-10": 0,
|
||
"11-50": 0,
|
||
"51-100": 0,
|
||
"100+": 0,
|
||
}
|
||
for bucket, count in mc_result.all():
|
||
if bucket is not None and bucket in by_message_count_bucket:
|
||
by_message_count_bucket[bucket] = int(count)
|
||
|
||
return SessionAnalytics(
|
||
total=total,
|
||
timeseries=timeseries,
|
||
by_channel=by_channel,
|
||
by_is_temporary=by_is_temporary,
|
||
by_message_count_bucket=by_message_count_bucket,
|
||
)
|
||
except IntegrityError as exc:
|
||
raise self._translate_db_error(exc, "channel_session") from exc
|
||
except SQLAlchemyError as exc:
|
||
raise self._translate_db_error(exc, "channel_session") from exc
|
||
except (NotFoundError, ConflictError, DependencyError):
|
||
raise
|
||
except Exception as exc:
|
||
await self._logger.error(
|
||
"channel_session_analyze_sessions_failed",
|
||
resource="channel_session",
|
||
error=str(exc),
|
||
)
|
||
raise DependencyError("channel_session", Error(str(exc))) from exc
|
||
|
||
async def analyzeDelivery(
|
||
self,
|
||
*,
|
||
channel_type: ChannelType | None,
|
||
start_time: datetime,
|
||
end_time: datetime,
|
||
) -> DeliveryAnalytics:
|
||
"""投递链路分析聚合查询(ANL-05)。
|
||
|
||
聚合统计投递成功率、失败率、重试分布与平均延迟。``channel_type``
|
||
通过 JOIN ``ChannelAccount`` 表过滤(Outbox 表无 ``channel_type`` 列)。
|
||
``latency_ms`` 全 NULL 时 ``avg_latency_ms`` 返回 None。
|
||
|
||
Raises:
|
||
DependencyError: 数据库故障。
|
||
"""
|
||
try:
|
||
# DB 时间列为 naive UTC,剥离 DTO 入参的 tzinfo(见 _to_naive_utc 说明)。
|
||
start_naive = _to_naive_utc(start_time)
|
||
end_naive = _to_naive_utc(end_time)
|
||
# 总数、成功数、失败数、平均延迟(条件聚合单次查询)
|
||
agg_stmt = select(
|
||
func.count(ChannelOutboxEntryORM.id).label("total"),
|
||
func.sum(
|
||
case(
|
||
(ChannelOutboxEntryORM.status == "sent", 1),
|
||
else_=0,
|
||
)
|
||
).label("success"),
|
||
func.sum(
|
||
case(
|
||
(
|
||
ChannelOutboxEntryORM.status.in_(["failed", "dead"]),
|
||
1,
|
||
),
|
||
else_=0,
|
||
)
|
||
).label("failure"),
|
||
func.avg(ChannelOutboxEntryORM.latency_ms).label("avg_latency"),
|
||
).where(
|
||
ChannelOutboxEntryORM.created_at >= start_naive,
|
||
ChannelOutboxEntryORM.created_at < end_naive,
|
||
ChannelOutboxEntryORM.is_deleted == 0,
|
||
)
|
||
if channel_type is not None:
|
||
agg_stmt = agg_stmt.join(
|
||
ChannelAccountORM,
|
||
ChannelOutboxEntryORM.account_id == ChannelAccountORM.id,
|
||
).where(ChannelAccountORM.channel_type == channel_type)
|
||
agg_row = (await self._db.execute(agg_stmt)).one()
|
||
total = int(agg_row.total or 0)
|
||
success_count = int(agg_row.success or 0)
|
||
failure_count = int(agg_row.failure or 0)
|
||
avg_latency = float(agg_row.avg_latency) if agg_row.avg_latency is not None else None
|
||
success_rate = (success_count / total * 100) if total > 0 else None
|
||
failure_rate = (failure_count / total * 100) if total > 0 else None
|
||
|
||
# 重试分布分桶
|
||
retry_bucket = case(
|
||
(ChannelOutboxEntryORM.retry_count == 0, "0"),
|
||
(ChannelOutboxEntryORM.retry_count == 1, "1"),
|
||
(ChannelOutboxEntryORM.retry_count == 2, "2"),
|
||
(
|
||
ChannelOutboxEntryORM.retry_count >= ChannelOutboxEntryORM.max_retry,
|
||
"max_reached",
|
||
),
|
||
else_="3+",
|
||
)
|
||
retry_stmt = (
|
||
select(retry_bucket, func.count(ChannelOutboxEntryORM.id))
|
||
.where(
|
||
ChannelOutboxEntryORM.created_at >= start_naive,
|
||
ChannelOutboxEntryORM.created_at < end_naive,
|
||
ChannelOutboxEntryORM.is_deleted == 0,
|
||
)
|
||
.group_by(retry_bucket)
|
||
)
|
||
if channel_type is not None:
|
||
retry_stmt = retry_stmt.join(
|
||
ChannelAccountORM,
|
||
ChannelOutboxEntryORM.account_id == ChannelAccountORM.id,
|
||
).where(ChannelAccountORM.channel_type == channel_type)
|
||
retry_result = await self._db.execute(retry_stmt)
|
||
retry_distribution: dict[str, int] = {
|
||
"0": 0,
|
||
"1": 0,
|
||
"2": 0,
|
||
"3+": 0,
|
||
"max_reached": 0,
|
||
}
|
||
for bucket, count in retry_result.all():
|
||
if bucket is not None and bucket in retry_distribution:
|
||
retry_distribution[bucket] = int(count)
|
||
|
||
return DeliveryAnalytics(
|
||
total=total,
|
||
success_rate=success_rate,
|
||
failure_rate=failure_rate,
|
||
retry_distribution=retry_distribution,
|
||
avg_latency_ms=avg_latency,
|
||
)
|
||
except IntegrityError as exc:
|
||
raise self._translate_db_error(exc, "channel_outbox") from exc
|
||
except SQLAlchemyError as exc:
|
||
raise self._translate_db_error(exc, "channel_outbox") from exc
|
||
except (NotFoundError, ConflictError, DependencyError):
|
||
raise
|
||
except Exception as exc:
|
||
await self._logger.error(
|
||
"channel_outbox_analyze_delivery_failed",
|
||
resource="channel_outbox",
|
||
error=str(exc),
|
||
)
|
||
raise DependencyError("channel_outbox", Error(str(exc))) from exc
|
||
|
||
async def getDeliveryLatencyDistribution(
|
||
self,
|
||
*,
|
||
channel_type: ChannelType | None,
|
||
start_time: datetime,
|
||
end_time: datetime,
|
||
) -> DeliveryLatencyDistribution:
|
||
"""投递延迟分布聚合查询(ANL-06)。
|
||
|
||
基于 ``latency_ms`` 字段计算 P50/P90/P99/max 分位数与直方图分桶。
|
||
``latency_ms`` 全 NULL 时全分位数返回 None。
|
||
|
||
Raises:
|
||
DependencyError: 数据库故障。
|
||
"""
|
||
try:
|
||
# DB 时间列为 naive UTC,剥离 DTO 入参的 tzinfo(见 _to_naive_utc 说明)。
|
||
start_naive = _to_naive_utc(start_time)
|
||
end_naive = _to_naive_utc(end_time)
|
||
# 分位数与最大值
|
||
pct_stmt = select(
|
||
func.percentile_cont(0.5).within_group(ChannelOutboxEntryORM.latency_ms).label("p50"),
|
||
func.percentile_cont(0.9).within_group(ChannelOutboxEntryORM.latency_ms).label("p90"),
|
||
func.percentile_cont(0.99).within_group(ChannelOutboxEntryORM.latency_ms).label("p99"),
|
||
func.max(ChannelOutboxEntryORM.latency_ms).label("max"),
|
||
).where(
|
||
ChannelOutboxEntryORM.created_at >= start_naive,
|
||
ChannelOutboxEntryORM.created_at < end_naive,
|
||
ChannelOutboxEntryORM.is_deleted == 0,
|
||
)
|
||
if channel_type is not None:
|
||
pct_stmt = pct_stmt.join(
|
||
ChannelAccountORM,
|
||
ChannelOutboxEntryORM.account_id == ChannelAccountORM.id,
|
||
).where(ChannelAccountORM.channel_type == channel_type)
|
||
pct_row = (await self._db.execute(pct_stmt)).one()
|
||
p50 = float(pct_row.p50) if pct_row.p50 is not None else None
|
||
p90 = float(pct_row.p90) if pct_row.p90 is not None else None
|
||
p99 = float(pct_row.p99) if pct_row.p99 is not None else None
|
||
max_ms = float(pct_row.max) if pct_row.max is not None else None
|
||
|
||
# 直方图分桶
|
||
latency_bucket = case(
|
||
(ChannelOutboxEntryORM.latency_ms <= 100, "0-100ms"),
|
||
(ChannelOutboxEntryORM.latency_ms <= 500, "100-500ms"),
|
||
(ChannelOutboxEntryORM.latency_ms <= 1000, "500ms-1s"),
|
||
(ChannelOutboxEntryORM.latency_ms <= 5000, "1-5s"),
|
||
else_="5s+",
|
||
)
|
||
hist_stmt = (
|
||
select(latency_bucket, func.count(ChannelOutboxEntryORM.id))
|
||
.where(
|
||
ChannelOutboxEntryORM.created_at >= start_naive,
|
||
ChannelOutboxEntryORM.created_at < end_naive,
|
||
ChannelOutboxEntryORM.is_deleted == 0,
|
||
ChannelOutboxEntryORM.latency_ms.isnot(None),
|
||
)
|
||
.group_by(latency_bucket)
|
||
)
|
||
if channel_type is not None:
|
||
hist_stmt = hist_stmt.join(
|
||
ChannelAccountORM,
|
||
ChannelOutboxEntryORM.account_id == ChannelAccountORM.id,
|
||
).where(ChannelAccountORM.channel_type == channel_type)
|
||
hist_result = await self._db.execute(hist_stmt)
|
||
histogram: dict[str, int] = {
|
||
"0-100ms": 0,
|
||
"100-500ms": 0,
|
||
"500ms-1s": 0,
|
||
"1-5s": 0,
|
||
"5s+": 0,
|
||
}
|
||
for bucket, count in hist_result.all():
|
||
if bucket is not None and bucket in histogram:
|
||
histogram[bucket] = int(count)
|
||
|
||
return DeliveryLatencyDistribution(
|
||
p50_ms=p50,
|
||
p90_ms=p90,
|
||
p99_ms=p99,
|
||
max_ms=max_ms,
|
||
histogram=histogram,
|
||
)
|
||
except IntegrityError as exc:
|
||
raise self._translate_db_error(exc, "channel_outbox") from exc
|
||
except SQLAlchemyError as exc:
|
||
raise self._translate_db_error(exc, "channel_outbox") from exc
|
||
except (NotFoundError, ConflictError, DependencyError):
|
||
raise
|
||
except Exception as exc:
|
||
await self._logger.error(
|
||
"channel_outbox_get_delivery_latency_distribution_failed",
|
||
resource="channel_outbox",
|
||
error=str(exc),
|
||
)
|
||
raise DependencyError("channel_outbox", Error(str(exc))) from exc
|
||
|
||
async def getDeliveryStats(
|
||
self,
|
||
query: DashboardDeliveryQuery,
|
||
) -> DashboardDeliveryResult:
|
||
"""投递 KPI 统计聚合查询(DSB-DELIVERY)。
|
||
|
||
聚合统计投递总数、成功/失败计数、成功率、平均延迟与 P95/P99 延迟
|
||
分位数,并按渠道切片返回 ``by_channel`` 统计。``query`` 的
|
||
``channel_type`` / ``start_time`` / ``end_time`` 均可选,缺省时返回
|
||
全局聚合。``channel_type`` 通过 JOIN ``ChannelAccount`` 表过滤。
|
||
|
||
Raises:
|
||
DependencyError: 数据库故障。
|
||
"""
|
||
try:
|
||
# DB 列 created_at 为 TIMESTAMP WITHOUT TIME ZONE(naive UTC 存储),
|
||
# DTO 传入 aware UTC datetime,需在 adapter 边界剥离 tzinfo,避免
|
||
# asyncpg "can't subtract offset-naive and offset-aware datetimes"。
|
||
start_naive = _to_naive_utc(query.start_time)
|
||
end_naive = _to_naive_utc(query.end_time)
|
||
# 全局聚合:总数、成功数、失败数、平均延迟、P95、P99、队列深度、死信数
|
||
agg_stmt = select(
|
||
func.count(ChannelOutboxEntryORM.id).label("total"),
|
||
func.sum(
|
||
case(
|
||
(ChannelOutboxEntryORM.status == "sent", 1),
|
||
else_=0,
|
||
)
|
||
).label("success"),
|
||
func.sum(
|
||
case(
|
||
(
|
||
ChannelOutboxEntryORM.status.in_(["failed", "dead"]),
|
||
1,
|
||
),
|
||
else_=0,
|
||
)
|
||
).label("failure"),
|
||
func.avg(ChannelOutboxEntryORM.latency_ms).label("avg_latency"),
|
||
func.percentile_cont(0.95).within_group(ChannelOutboxEntryORM.latency_ms).label("p95"),
|
||
func.percentile_cont(0.99).within_group(ChannelOutboxEntryORM.latency_ms).label("p99"),
|
||
func.sum(
|
||
case(
|
||
(ChannelOutboxEntryORM.status == "pending", 1),
|
||
else_=0,
|
||
)
|
||
).label("queue_depth"),
|
||
func.sum(
|
||
case(
|
||
(ChannelOutboxEntryORM.status == "dead", 1),
|
||
else_=0,
|
||
)
|
||
).label("dead_count"),
|
||
).where(ChannelOutboxEntryORM.is_deleted == 0)
|
||
if query.channel_type is not None:
|
||
agg_stmt = agg_stmt.join(
|
||
ChannelAccountORM,
|
||
ChannelOutboxEntryORM.account_id == ChannelAccountORM.id,
|
||
).where(ChannelAccountORM.channel_type == query.channel_type)
|
||
if start_naive is not None:
|
||
agg_stmt = agg_stmt.where(ChannelOutboxEntryORM.created_at >= start_naive)
|
||
if end_naive is not None:
|
||
agg_stmt = agg_stmt.where(ChannelOutboxEntryORM.created_at < end_naive)
|
||
agg_row = (await self._db.execute(agg_stmt)).one()
|
||
total_sent = int(agg_row.total or 0)
|
||
success_count = int(agg_row.success or 0)
|
||
failed_count = int(agg_row.failure or 0)
|
||
success_rate = (success_count / total_sent * 100) if total_sent > 0 else None
|
||
# 无投递样本时延迟统一返回 None,避免 0ms 被误解为真实低延迟。
|
||
avg_latency = float(agg_row.avg_latency) if total_sent > 0 and agg_row.avg_latency is not None else None
|
||
p95 = float(agg_row.p95) if total_sent > 0 and agg_row.p95 is not None else None
|
||
p99 = float(agg_row.p99) if total_sent > 0 and agg_row.p99 is not None else None
|
||
queue_depth = int(agg_row.queue_depth or 0)
|
||
dead_count = int(agg_row.dead_count or 0)
|
||
|
||
# 按渠道切片统计(JOIN ChannelAccount 获取 channel_type)
|
||
channel_stmt = (
|
||
select(
|
||
ChannelAccountORM.channel_type,
|
||
func.count(ChannelOutboxEntryORM.id).label("sent"),
|
||
func.sum(
|
||
case(
|
||
(ChannelOutboxEntryORM.status == "sent", 1),
|
||
else_=0,
|
||
)
|
||
).label("success"),
|
||
)
|
||
.join(
|
||
ChannelAccountORM,
|
||
ChannelOutboxEntryORM.account_id == ChannelAccountORM.id,
|
||
)
|
||
.where(ChannelOutboxEntryORM.is_deleted == 0)
|
||
.group_by(ChannelAccountORM.channel_type)
|
||
)
|
||
if start_naive is not None:
|
||
channel_stmt = channel_stmt.where(ChannelOutboxEntryORM.created_at >= start_naive)
|
||
if end_naive is not None:
|
||
channel_stmt = channel_stmt.where(ChannelOutboxEntryORM.created_at < end_naive)
|
||
channel_result = await self._db.execute(channel_stmt)
|
||
by_channel: list[ChannelDeliveryStat] = []
|
||
for row in channel_result.all():
|
||
ch_sent = int(row.sent or 0)
|
||
ch_success = int(row.success or 0)
|
||
ch_rate = (ch_success / ch_sent * 100) if ch_sent > 0 else None
|
||
by_channel.append(
|
||
ChannelDeliveryStat(
|
||
channel_type=row.channel_type,
|
||
sent=ch_sent,
|
||
success_rate=ch_rate,
|
||
)
|
||
)
|
||
|
||
return DashboardDeliveryResult(
|
||
total_sent=total_sent,
|
||
success_count=success_count,
|
||
failed_count=failed_count,
|
||
success_rate=success_rate,
|
||
avg_latency_ms=avg_latency,
|
||
p95_latency_ms=p95,
|
||
p99_latency_ms=p99,
|
||
current_queue_depth=queue_depth,
|
||
dead_letter_count=dead_count,
|
||
by_channel=tuple(by_channel),
|
||
)
|
||
except IntegrityError as exc:
|
||
raise self._translate_db_error(exc, "channel_outbox") from exc
|
||
except SQLAlchemyError as exc:
|
||
raise self._translate_db_error(exc, "channel_outbox") from exc
|
||
except (NotFoundError, ConflictError, DependencyError):
|
||
raise
|
||
except Exception as exc:
|
||
await self._logger.error(
|
||
"channel_outbox_get_delivery_stats_failed",
|
||
resource="channel_outbox",
|
||
error=str(exc),
|
||
)
|
||
raise DependencyError("channel_outbox", Error(str(exc))) from exc
|
||
|
||
async def getDeliveryFunnel(
|
||
self,
|
||
*,
|
||
channel_type: ChannelType | None,
|
||
start_time: datetime,
|
||
end_time: datetime,
|
||
) -> DeliveryFunnel:
|
||
"""投递漏斗分析聚合查询(ANL-07)。
|
||
|
||
按 ``funnel_node`` 字段分组计数各节点数量并计算相对 enter 的转化率。
|
||
``funnel_node`` 全 NULL 时全节点计数返回 0。
|
||
|
||
Raises:
|
||
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(
|
||
ChannelOutboxEntryORM.funnel_node,
|
||
func.count(ChannelOutboxEntryORM.id),
|
||
)
|
||
.where(
|
||
ChannelOutboxEntryORM.created_at >= start_naive,
|
||
ChannelOutboxEntryORM.created_at < end_naive,
|
||
ChannelOutboxEntryORM.is_deleted == 0,
|
||
)
|
||
.group_by(ChannelOutboxEntryORM.funnel_node)
|
||
)
|
||
if channel_type is not None:
|
||
stmt = stmt.join(
|
||
ChannelAccountORM,
|
||
ChannelOutboxEntryORM.account_id == ChannelAccountORM.id,
|
||
).where(ChannelAccountORM.channel_type == channel_type)
|
||
result = await self._db.execute(stmt)
|
||
counts = {"enter": 0, "sent": 0, "suppressed": 0, "failed": 0, "dead": 0}
|
||
for node, count in result.all():
|
||
if node is not None and node in counts:
|
||
counts[node] = int(count)
|
||
enter = counts["enter"]
|
||
conversion_rate = {
|
||
"sent": (counts["sent"] / enter * 100) if enter > 0 else 0.0,
|
||
"suppressed": (counts["suppressed"] / enter * 100) if enter > 0 else 0.0,
|
||
"failed": (counts["failed"] / enter * 100) if enter > 0 else 0.0,
|
||
"dead": (counts["dead"] / enter * 100) if enter > 0 else 0.0,
|
||
}
|
||
return DeliveryFunnel(
|
||
enter=enter,
|
||
sent=counts["sent"],
|
||
suppressed=counts["suppressed"],
|
||
failed=counts["failed"],
|
||
dead=counts["dead"],
|
||
conversion_rate=conversion_rate,
|
||
)
|
||
except IntegrityError as exc:
|
||
raise self._translate_db_error(exc, "channel_outbox") from exc
|
||
except SQLAlchemyError as exc:
|
||
raise self._translate_db_error(exc, "channel_outbox") from exc
|
||
except (NotFoundError, ConflictError, DependencyError):
|
||
raise
|
||
except Exception as exc:
|
||
await self._logger.error(
|
||
"channel_outbox_get_delivery_funnel_failed",
|
||
resource="channel_outbox",
|
||
error=str(exc),
|
||
)
|
||
raise DependencyError("channel_outbox", Error(str(exc))) from exc
|
||
|
||
async def getAccountActivity(
|
||
self,
|
||
query: AccountAnalyticsQuery,
|
||
) -> tuple[AccountActivityStat, ...]:
|
||
"""账户活跃度聚合查询(ANL-ACCOUNTS)。
|
||
|
||
按 ``query.channel_type`` 过滤,在时间范围内按账户分组聚合消息数与
|
||
活跃天数(通过 JOIN ``conversations`` 表获取 ``channel_account_id``)。
|
||
会话数通过单独子查询 ``channel_sessions`` JOIN ``channel_accounts``
|
||
获取(``channel_sessions.account_id`` 为 ORM 外键整型,需 JOIN
|
||
``channel_accounts`` 取业务标识 ``account_id``)。
|
||
|
||
``avg_daily_messages`` = ``message_count`` / ``active_days``
|
||
(``active_days`` 为 0 时记 0.0,避免除零)。
|
||
|
||
Raises:
|
||
DependencyError: 数据库故障。
|
||
"""
|
||
try:
|
||
# DB 时间列为 naive UTC,剥离 DTO 入参的 tzinfo(见 _to_naive_utc 说明)。
|
||
start_naive = _to_naive_utc(query.start_time)
|
||
end_naive = _to_naive_utc(query.end_time)
|
||
# 消息维度聚合:JOIN Message → Conversation,按 channel_account_id 分组
|
||
msg_stmt = (
|
||
select(
|
||
ConversationORM.channel_account_id.label("account_id"),
|
||
ConversationORM.channel_type.label("channel_type"),
|
||
func.count(MessageORM.id).label("message_count"),
|
||
func.count(func.distinct(func.date(MessageORM.created_at))).label("active_days"),
|
||
)
|
||
.join(
|
||
ConversationORM,
|
||
ConversationORM.id == MessageORM.conversation_id,
|
||
)
|
||
.where(
|
||
MessageORM.created_at >= start_naive,
|
||
MessageORM.created_at < end_naive,
|
||
ConversationORM.channel_account_id.isnot(None),
|
||
)
|
||
.group_by(
|
||
ConversationORM.channel_account_id,
|
||
ConversationORM.channel_type,
|
||
)
|
||
)
|
||
if query.channel_type is not None:
|
||
msg_stmt = msg_stmt.where(ConversationORM.channel_type == query.channel_type)
|
||
msg_result = await self._db.execute(msg_stmt)
|
||
|
||
# 会话维度聚合:JOIN ChannelSession → ChannelAccount,按业务 account_id 分组
|
||
session_stmt = (
|
||
select(
|
||
ChannelAccountORM.account_id.label("account_id"),
|
||
ChannelAccountORM.channel_type.label("channel_type"),
|
||
func.count(ChannelSessionORM.id).label("session_count"),
|
||
)
|
||
.join(
|
||
ChannelAccountORM,
|
||
ChannelAccountORM.id == ChannelSessionORM.account_id,
|
||
)
|
||
.where(
|
||
ChannelSessionORM.created_at >= start_naive,
|
||
ChannelSessionORM.created_at < end_naive,
|
||
ChannelSessionORM.is_deleted == 0,
|
||
)
|
||
.group_by(
|
||
ChannelAccountORM.account_id,
|
||
ChannelAccountORM.channel_type,
|
||
)
|
||
)
|
||
if query.channel_type is not None:
|
||
session_stmt = session_stmt.where(ChannelAccountORM.channel_type == query.channel_type)
|
||
session_result = await self._db.execute(session_stmt)
|
||
session_map: dict[tuple[str, str], int] = {}
|
||
for row in session_result.all():
|
||
session_map[(row.account_id, row.channel_type)] = int(row.session_count)
|
||
|
||
stats: list[AccountActivityStat] = []
|
||
for row in msg_result.all():
|
||
account_id = row.account_id
|
||
channel_type = row.channel_type or ""
|
||
message_count = int(row.message_count)
|
||
active_days = int(row.active_days) or 0
|
||
session_count = session_map.get((account_id, channel_type), 0)
|
||
avg_daily = message_count / active_days if active_days > 0 else 0.0
|
||
stats.append(
|
||
AccountActivityStat(
|
||
account_id=account_id,
|
||
channel_type=channel_type,
|
||
message_count=message_count,
|
||
session_count=session_count,
|
||
active_days=active_days,
|
||
avg_daily_messages=avg_daily,
|
||
)
|
||
)
|
||
return tuple(stats)
|
||
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:
|
||
await self._logger.error(
|
||
"message_get_account_activity_failed",
|
||
resource="message",
|
||
error=str(exc),
|
||
)
|
||
raise DependencyError("message", Error(str(exc))) from exc
|
||
|
||
async def getAccountTrend(
|
||
self,
|
||
query: AccountAnalyticsQuery,
|
||
) -> tuple[TrendDataPoint, ...]:
|
||
"""账户活跃度趋势聚合查询(ANL-ACCOUNTS trend)。
|
||
|
||
按 ``query.granularity`` 分桶时间序列,统计各时间桶的去重活跃账户数
|
||
(``channel_account_id`` 去重),仅含 ``channel_account_id`` 非空
|
||
的会话关联消息。
|
||
|
||
Raises:
|
||
DependencyError: 数据库故障。
|
||
"""
|
||
try:
|
||
# DB 时间列为 naive UTC,剥离 DTO 入参的 tzinfo(见 _to_naive_utc 说明)。
|
||
start_naive = _to_naive_utc(query.start_time)
|
||
end_naive = _to_naive_utc(query.end_time)
|
||
bucket_expr = func.date_trunc(query.granularity, MessageORM.created_at)
|
||
stmt = (
|
||
select(
|
||
bucket_expr.label("bucket"),
|
||
func.count(func.distinct(ConversationORM.channel_account_id)).label("active_accounts"),
|
||
)
|
||
.join(
|
||
ConversationORM,
|
||
ConversationORM.id == MessageORM.conversation_id,
|
||
)
|
||
.where(
|
||
MessageORM.created_at >= start_naive,
|
||
MessageORM.created_at < end_naive,
|
||
ConversationORM.channel_account_id.isnot(None),
|
||
)
|
||
.group_by(bucket_expr)
|
||
.order_by(bucket_expr)
|
||
)
|
||
if query.channel_type is not None:
|
||
stmt = stmt.where(ConversationORM.channel_type == query.channel_type)
|
||
result = await self._db.execute(stmt)
|
||
return tuple(
|
||
TrendDataPoint(
|
||
timestamp=bucket,
|
||
value=int(count),
|
||
)
|
||
for bucket, count in result.all()
|
||
)
|
||
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:
|
||
await self._logger.error(
|
||
"message_get_account_trend_failed",
|
||
resource="message",
|
||
error=str(exc),
|
||
)
|
||
raise DependencyError("message", Error(str(exc))) from exc
|
||
|
||
async def getPeerActivity(
|
||
self,
|
||
query: PeerAnalyticsQuery,
|
||
) -> tuple[PeerActivityStat, ...]:
|
||
"""对端活跃度聚合查询(ANL-PEERS)。
|
||
|
||
三表 JOIN(Message → Conversation → ChannelSession)按 ``peer_id``
|
||
分组聚合消息数、会话数与首末消息时间。``session_count`` 通过
|
||
``COUNT(DISTINCT ChannelSession.session_id)`` 去重。结果按
|
||
``message_count`` 降序、``limit`` 截断。
|
||
|
||
``account_id`` 过滤时额外 JOIN ``ChannelAccount``(业务标识匹配)。
|
||
``ChannelSession`` 软删除条目(``is_deleted=1``)已排除。
|
||
|
||
Raises:
|
||
DependencyError: 数据库故障。
|
||
"""
|
||
try:
|
||
# DB 时间列为 naive UTC,剥离 DTO 入参的 tzinfo(见 _to_naive_utc 说明)。
|
||
start_naive = _to_naive_utc(query.start_time)
|
||
end_naive = _to_naive_utc(query.end_time)
|
||
stmt = (
|
||
select(
|
||
ChannelSessionORM.peer_id.label("peer_id"),
|
||
ChannelSessionORM.channel_type.label("channel_type"),
|
||
func.count(MessageORM.id).label("message_count"),
|
||
func.count(func.distinct(ChannelSessionORM.session_id)).label("session_count"),
|
||
func.min(MessageORM.created_at).label("first_seen"),
|
||
func.max(MessageORM.created_at).label("last_seen"),
|
||
)
|
||
.join(
|
||
ConversationORM,
|
||
ConversationORM.id == MessageORM.conversation_id,
|
||
)
|
||
.join(
|
||
ChannelSessionORM,
|
||
ChannelSessionORM.conversation_id == ConversationORM.id,
|
||
)
|
||
.where(
|
||
MessageORM.created_at >= start_naive,
|
||
MessageORM.created_at < end_naive,
|
||
ChannelSessionORM.is_deleted == 0,
|
||
)
|
||
.group_by(
|
||
ChannelSessionORM.peer_id,
|
||
ChannelSessionORM.channel_type,
|
||
)
|
||
.order_by(func.count(MessageORM.id).desc())
|
||
.limit(query.limit)
|
||
)
|
||
if query.channel_type is not None:
|
||
stmt = stmt.where(ChannelSessionORM.channel_type == query.channel_type)
|
||
if query.account_id is not None:
|
||
stmt = stmt.join(
|
||
ChannelAccountORM,
|
||
ChannelAccountORM.id == ChannelSessionORM.account_id,
|
||
).where(ChannelAccountORM.account_id == query.account_id)
|
||
result = await self._db.execute(stmt)
|
||
return tuple(
|
||
PeerActivityStat(
|
||
peer_id=row.peer_id,
|
||
channel_type=row.channel_type,
|
||
message_count=int(row.message_count),
|
||
session_count=int(row.session_count),
|
||
first_seen=row.first_seen,
|
||
last_seen=row.last_seen,
|
||
)
|
||
for row in result.all()
|
||
)
|
||
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:
|
||
await self._logger.error(
|
||
"message_get_peer_activity_failed",
|
||
resource="message",
|
||
error=str(exc),
|
||
)
|
||
raise DependencyError("message", Error(str(exc))) from exc
|
||
|
||
# ═══════════════════════════════════════════════════════════════════════════
|
||
# 路由绑定 CRUD(RouteBindingRepositoryPort)
|
||
# ═══════════════════════════════════════════════════════════════════════════
|
||
|
||
async def saveRouteBinding(
|
||
self,
|
||
cmd: SaveRouteBindingCmd,
|
||
operator: Operator,
|
||
tx: TransactionContext | None = None,
|
||
) -> RouteBindingRule:
|
||
"""保存路由绑定,返回含时间戳的 RouteBindingRule。
|
||
|
||
``binding_id`` 由适配器生成(``uuid4`` 十六进制),``created_by`` /
|
||
``updated_by`` 取自 ``operator.user_id``。``binding_id`` 重复或唯一
|
||
约束冲突时 ``IntegrityError`` 翻译为 ``ConflictError``。
|
||
|
||
Raises:
|
||
ConflictError: 唯一约束冲突(同账户同 tier 同值已存在未删除规则)。
|
||
DependencyError: 数据库故障。
|
||
"""
|
||
commit = self._should_commit(tx)
|
||
data: dict[str, Any] = {
|
||
"binding_id": uuid.uuid4().hex,
|
||
"channel_type": cmd.channel_type,
|
||
"account_id": cmd.account_id,
|
||
"match_source": cmd.match_source,
|
||
"match_value": cmd.match_value,
|
||
"agent_binding": cmd.agent_binding,
|
||
"enabled": True,
|
||
"description": cmd.description,
|
||
"created_by": operator.user_id,
|
||
"updated_by": operator.user_id,
|
||
}
|
||
try:
|
||
orm = await self._repos.route_binding.create(data, commit=commit)
|
||
return orm_to_route_binding(orm)
|
||
except IntegrityError as exc:
|
||
raise self._translate_db_error(exc, "route_binding") from exc
|
||
except SQLAlchemyError as exc:
|
||
raise self._translate_db_error(exc, "route_binding") from exc
|
||
except (NotFoundError, ConflictError, DependencyError):
|
||
raise
|
||
except Exception as exc:
|
||
await self._logger.error(
|
||
"route_binding_save_failed",
|
||
resource="route_binding",
|
||
error=str(exc),
|
||
)
|
||
raise DependencyError("route_binding", Error(str(exc))) from exc
|
||
|
||
async def updateRouteBinding(
|
||
self,
|
||
cmd: UpdateRouteBindingCmd,
|
||
operator: Operator,
|
||
tx: TransactionContext | None = None,
|
||
) -> RouteBindingRule:
|
||
"""更新路由绑定,仅更新 cmd 中非 None 字段;不存在抛 NotFoundError。
|
||
|
||
Raises:
|
||
NotFoundError: binding_id 不存在。
|
||
ConflictError: 并发冲突。
|
||
DependencyError: 数据库故障。
|
||
"""
|
||
commit = self._should_commit(tx)
|
||
try:
|
||
orm = await self._repos.route_binding.get_by_binding_id(cmd.binding_id, for_update=True)
|
||
if orm is None:
|
||
raise NotFoundError("route_binding", cmd.binding_id)
|
||
data: dict[str, Any] = {"updated_by": operator.user_id}
|
||
if cmd.agent_binding is not None:
|
||
data["agent_binding"] = cmd.agent_binding
|
||
if cmd.match_value is not None:
|
||
data["match_value"] = cmd.match_value
|
||
if cmd.enabled is not None:
|
||
data["enabled"] = cmd.enabled
|
||
if cmd.description is not None:
|
||
data["description"] = cmd.description
|
||
updated = await self._repos.route_binding.update(orm, data, commit=commit)
|
||
return orm_to_route_binding(updated)
|
||
except IntegrityError as exc:
|
||
raise self._translate_db_error(exc, "route_binding") from exc
|
||
except SQLAlchemyError as exc:
|
||
raise self._translate_db_error(exc, "route_binding") from exc
|
||
except (NotFoundError, ConflictError, DependencyError):
|
||
raise
|
||
except Exception as exc:
|
||
await self._logger.error(
|
||
"route_binding_update_failed",
|
||
resource="route_binding",
|
||
error=str(exc),
|
||
)
|
||
raise DependencyError("route_binding", Error(str(exc))) from exc
|
||
|
||
async def getRouteBinding(self, binding_id: str) -> RouteBindingRule | None:
|
||
"""按 binding_id 查询路由绑定;不存在返回 None。
|
||
|
||
Raises:
|
||
DependencyError: 数据库故障。
|
||
"""
|
||
try:
|
||
orm = await self._repos.route_binding.get_by_binding_id(binding_id)
|
||
if orm is None:
|
||
return None
|
||
return orm_to_route_binding(orm)
|
||
except IntegrityError as exc:
|
||
raise self._translate_db_error(exc, "route_binding") from exc
|
||
except SQLAlchemyError as exc:
|
||
raise self._translate_db_error(exc, "route_binding") from exc
|
||
except (NotFoundError, ConflictError, DependencyError):
|
||
raise
|
||
except Exception as exc:
|
||
await self._logger.error(
|
||
"route_binding_get_failed",
|
||
resource="route_binding",
|
||
error=str(exc),
|
||
)
|
||
raise DependencyError("route_binding", Error(str(exc))) from exc
|
||
|
||
async def listRouteBindings(
|
||
self,
|
||
filter: RouteBindingFilter,
|
||
limit: int = 1000,
|
||
offset: int = 0,
|
||
) -> tuple[RouteBindingRule, ...]:
|
||
"""按过滤条件列表查询路由绑定。
|
||
|
||
Raises:
|
||
DependencyError: 数据库故障。
|
||
"""
|
||
try:
|
||
orms = await self._repos.route_binding.list(filter=filter, limit=limit, offset=offset)
|
||
return tuple(orm_to_route_binding(orm) for orm in orms)
|
||
except IntegrityError as exc:
|
||
raise self._translate_db_error(exc, "route_binding") from exc
|
||
except SQLAlchemyError as exc:
|
||
raise self._translate_db_error(exc, "route_binding") from exc
|
||
except (NotFoundError, ConflictError, DependencyError):
|
||
raise
|
||
except Exception as exc:
|
||
await self._logger.error(
|
||
"route_binding_list_failed",
|
||
resource="route_binding",
|
||
error=str(exc),
|
||
)
|
||
raise DependencyError("route_binding", Error(str(exc))) from exc
|
||
|
||
async def countRouteBindings(
|
||
self,
|
||
filter: RouteBindingFilter,
|
||
) -> int:
|
||
"""按过滤条件统计路由绑定总数。
|
||
|
||
Raises:
|
||
DependencyError: 数据库故障。
|
||
"""
|
||
try:
|
||
return await self._repos.route_binding.count(filter=filter)
|
||
except IntegrityError as exc:
|
||
raise self._translate_db_error(exc, "route_binding") from exc
|
||
except SQLAlchemyError as exc:
|
||
raise self._translate_db_error(exc, "route_binding") from exc
|
||
except (NotFoundError, ConflictError, DependencyError):
|
||
raise
|
||
except Exception as exc:
|
||
await self._logger.error(
|
||
"route_binding_count_failed",
|
||
resource="route_binding",
|
||
error=str(exc),
|
||
)
|
||
raise DependencyError("route_binding", Error(str(exc))) from exc
|
||
|
||
async def deleteRouteBinding(
|
||
self,
|
||
binding_id: str,
|
||
operator: Operator,
|
||
tx: TransactionContext | None = None,
|
||
) -> bool:
|
||
"""软删除路由绑定;binding_id 不存在(含已软删除)抛 NotFoundError。
|
||
|
||
Raises:
|
||
NotFoundError: binding_id 不存在(含已软删除)。
|
||
DependencyError: 数据库故障。
|
||
"""
|
||
commit = self._should_commit(tx)
|
||
try:
|
||
deleted = await self._repos.route_binding.soft_delete(binding_id, operator=operator.user_id, commit=commit)
|
||
if not deleted:
|
||
raise NotFoundError("route_binding", binding_id)
|
||
return deleted
|
||
except IntegrityError as exc:
|
||
raise self._translate_db_error(exc, "route_binding") from exc
|
||
except SQLAlchemyError as exc:
|
||
raise self._translate_db_error(exc, "route_binding") from exc
|
||
except (NotFoundError, ConflictError, DependencyError):
|
||
raise
|
||
except Exception as exc:
|
||
await self._logger.error(
|
||
"route_binding_delete_failed",
|
||
resource="route_binding",
|
||
error=str(exc),
|
||
)
|
||
raise DependencyError("route_binding", Error(str(exc))) from exc
|
||
|
||
async def listEnabledByAccount(
|
||
self,
|
||
channel_type: ChannelType,
|
||
account_id: str,
|
||
) -> tuple[RouteBindingRule, ...]:
|
||
"""加载某账户下所有启用规则,供 RouteMatchRegistry 预热使用。
|
||
|
||
Raises:
|
||
DependencyError: 数据库故障。
|
||
"""
|
||
try:
|
||
orms = await self._repos.route_binding.list_enabled_by_account(channel_type, account_id)
|
||
return tuple(orm_to_route_binding(orm) for orm in orms)
|
||
except IntegrityError as exc:
|
||
raise self._translate_db_error(exc, "route_binding") from exc
|
||
except SQLAlchemyError as exc:
|
||
raise self._translate_db_error(exc, "route_binding") from exc
|
||
except (NotFoundError, ConflictError, DependencyError):
|
||
raise
|
||
except Exception as exc:
|
||
await self._logger.error(
|
||
"route_binding_list_enabled_failed",
|
||
resource="route_binding",
|
||
error=str(exc),
|
||
)
|
||
raise DependencyError("route_binding", Error(str(exc))) from exc
|
||
|
||
|
||
def _coerceFilterDateTime(value: Any) -> datetime:
|
||
"""将 filter 字典中的时间值转换为 naive datetime。
|
||
|
||
支持 ``datetime`` 与 ISO 8601 字符串;带时区时转换为 naive UTC 以对齐
|
||
ORM 列(naive datetime)。非法格式抛 ``ValidationError``(400),
|
||
避免原生异常被适配器兜底翻译为 ``DependencyError``(502)。
|
||
"""
|
||
if isinstance(value, datetime):
|
||
dt = value
|
||
elif isinstance(value, str):
|
||
try:
|
||
dt = datetime.fromisoformat(value)
|
||
except ValueError as exc:
|
||
raise ValidationError(
|
||
"inactive_before",
|
||
f"invalid inactive_before format: {value!r}",
|
||
) from exc
|
||
else:
|
||
raise ValidationError(
|
||
"inactive_before",
|
||
f"inactive_before must be ISO 8601 string or datetime, got {type(value).__name__}",
|
||
)
|
||
if dt.tzinfo is not None:
|
||
dt = dt.astimezone(tz=None).replace(tzinfo=None)
|
||
return dt
|