本次提交包含多项代码优化与规范修正: 1. 文档与注释优化:修正注释术语、补充注解与FR编号 2. 代码格式调整:统一空格、换行与缩进规范 3. 类型与接口完善:补充__all__导出、修正返回类型注解 4. 错误处理增强:新增领域错误类与校验逻辑 5. 依赖与导入调整:修复路径引用、统一时区导入 6. 协议与契约更新:完善接口文档与一致性注解
559 lines
19 KiB
Python
559 lines
19 KiB
Python
"""聚合视图域 analytics 子域契约 DTO。
|
||
|
||
定义聚合视图域 analytics 子域 9 个跨子域深度分析端点(ANL-01/02/04/05/06/07
|
||
/ACCOUNTS/PEERS/CR)返回的不可变值对象。所有 DTO 均为 ``dataclass(frozen=True)``,
|
||
仅依赖标准库,用于按时间 / 类型 / 状态切片的细分聚合展示,供管理后台运营审计与
|
||
运维排障使用。
|
||
|
||
与 dashboard 子域的区分:dashboard 提供总览计数,analytics 提供按维度切片的
|
||
细分聚合;与单子域 stats(如 OBX-02 outbox stats)的区分:单子域统计归各子域
|
||
router,本域仅承载跨子域深度分析视图。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
from dataclasses import dataclass
|
||
from datetime import datetime
|
||
from typing import Any, Literal
|
||
|
||
from yuxi.channels.contract.dtos.channel import ChannelType
|
||
from yuxi.channels.contract.dtos.common import CategoryStat, TrendDataPoint
|
||
from yuxi.channels.contract.errors import ValidationError
|
||
from yuxi.utils.datetime_utils import format_utc_datetime
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class TimeSeriesPoint:
|
||
"""时间序列数据点。
|
||
|
||
描述按时间粒度切片后的单个时间桶计数,供消息与会话分析的趋势展示使用。
|
||
|
||
字段:
|
||
timestamp: 时间桶起始时间(ISO 8601 字符串)。
|
||
value: 计数值。
|
||
"""
|
||
|
||
timestamp: str
|
||
value: int
|
||
|
||
def to_dict(self) -> dict[str, Any]:
|
||
return {"timestamp": self.timestamp, "value": self.value}
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class MessageAnalytics:
|
||
"""消息深度分析结果(ANL-01)。
|
||
|
||
描述时间范围内消息量按时间 / 渠道 / 角色 / 投递状态切片的细分聚合,由
|
||
``getMessageAnalytics`` 返回,供运营审计按多维切片定位消息量趋势。
|
||
|
||
字段:
|
||
total: 时间范围内消息总数。
|
||
timeseries: 按时间粒度切片的消息量趋势。
|
||
by_channel: 按渠道类型分组的计数 dict(key 为 channel_type 字符串值)。
|
||
by_role: 按发送角色分组的计数 dict(key 为 user / assistant / system / tool)。
|
||
by_delivery_status: 按投递状态分组的计数 dict。
|
||
"""
|
||
|
||
total: int
|
||
timeseries: tuple[TimeSeriesPoint, ...]
|
||
by_channel: dict[str, int]
|
||
by_role: dict[str, int]
|
||
by_delivery_status: dict[str, int]
|
||
|
||
def to_dict(self) -> dict[str, Any]:
|
||
"""序列化为 dict,递归处理嵌套 ``TimeSeriesPoint`` 为 dict 列表。"""
|
||
return {
|
||
"total": self.total,
|
||
"timeseries": [point.to_dict() for point in self.timeseries],
|
||
"by_channel": self.by_channel,
|
||
"by_role": self.by_role,
|
||
"by_delivery_status": self.by_delivery_status,
|
||
}
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class MessageDistribution:
|
||
"""消息类型分布结果(ANL-02)。
|
||
|
||
描述时间范围内消息按类型分组的计数与占比,由 ``getMessageDistribution`` 返回,
|
||
供内容运营按类型(text / card / attachment / mixed)定位消息构成。
|
||
|
||
字段:
|
||
total: 时间范围内消息总数。
|
||
by_type: 按消息类型分组的计数 dict。
|
||
by_type_percent: 按消息类型分组的占比 dict(0-100)。
|
||
"""
|
||
|
||
total: int
|
||
by_type: dict[str, int]
|
||
by_type_percent: dict[str, float]
|
||
|
||
def to_dict(self) -> dict[str, Any]:
|
||
"""序列化为 dict,供控制面结果返回与日志输出使用。"""
|
||
return {
|
||
"total": self.total,
|
||
"by_type": self.by_type,
|
||
"by_type_percent": self.by_type_percent,
|
||
}
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class SessionAnalytics:
|
||
"""会话分析结果(ANL-04)。
|
||
|
||
描述时间范围内会话量按时间 / 渠道 / 临时性 / 消息数区间切片的细分聚合,由
|
||
``getSessionAnalytics`` 返回,供运营审计按多维切片定位会话量趋势。
|
||
|
||
字段:
|
||
total: 时间范围内活跃会话总数。
|
||
timeseries: 按时间粒度切片的会话量趋势。
|
||
by_channel: 按渠道类型分组的计数 dict(key 为 channel_type 字符串值)。
|
||
by_is_temporary: 按是否临时会话分组的计数 dict(key 为 "true" / "false")。
|
||
by_message_count_bucket: 按消息数区间分组的计数 dict
|
||
(key 为 "0-10" / "11-50" / "51-100" / "100+")。
|
||
"""
|
||
|
||
total: int
|
||
timeseries: tuple[TimeSeriesPoint, ...]
|
||
by_channel: dict[str, int]
|
||
by_is_temporary: dict[str, int]
|
||
by_message_count_bucket: dict[str, int]
|
||
|
||
def to_dict(self) -> dict[str, Any]:
|
||
"""序列化为 dict,递归处理嵌套 ``TimeSeriesPoint`` 为 dict 列表。"""
|
||
return {
|
||
"total": self.total,
|
||
"timeseries": [point.to_dict() for point in self.timeseries],
|
||
"by_channel": self.by_channel,
|
||
"by_is_temporary": self.by_is_temporary,
|
||
"by_message_count_bucket": self.by_message_count_bucket,
|
||
}
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class DeliveryAnalytics:
|
||
"""投递链路分析结果(ANL-05)。
|
||
|
||
与现有 ``OutboxStats``(按状态分组计数)的关系:``DeliveryAnalytics``
|
||
是 ``OutboxStats`` 的扩展,新增 success_rate / failure_rate /
|
||
retry_distribution / avg_latency_ms 四个聚合维度,由
|
||
``getDeliveryAnalytics`` 返回,供运维排障定位投递成功率与重试分布。
|
||
|
||
字段:
|
||
total: 时间范围内投递条目总数。
|
||
success_rate: 投递成功率(0-100)。
|
||
failure_rate: 投递失败率(0-100)。
|
||
retry_distribution: 按重试次数分组的计数 dict
|
||
(key 为 "0" / "1" / "2" / "3+" / "max_reached")。
|
||
avg_latency_ms: 平均投递延迟(毫秒),无 latency_ms 数据时为 None。
|
||
"""
|
||
|
||
total: int
|
||
success_rate: float
|
||
failure_rate: float
|
||
retry_distribution: dict[str, int]
|
||
avg_latency_ms: float | None
|
||
|
||
def to_dict(self) -> dict[str, Any]:
|
||
"""序列化为 dict,``avg_latency_ms`` 为 None 时返回 None。"""
|
||
return {
|
||
"total": self.total,
|
||
"success_rate": self.success_rate,
|
||
"failure_rate": self.failure_rate,
|
||
"retry_distribution": self.retry_distribution,
|
||
"avg_latency_ms": self.avg_latency_ms,
|
||
}
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class DeliveryLatencyDistribution:
|
||
"""投递延迟分布结果(ANL-06)。
|
||
|
||
描述投递延迟的分位数与直方图分布,由 ``getDeliveryLatencyDistribution``
|
||
返回,供运维排障定位长尾延迟。所有分位数字段无 latency_ms 数据时为 None。
|
||
|
||
字段:
|
||
p50_ms: P50 延迟(毫秒),无数据时为 None。
|
||
p90_ms: P90 延迟(毫秒),无数据时为 None。
|
||
p99_ms: P99 延迟(毫秒),无数据时为 None。
|
||
max_ms: 最大延迟(毫秒),无数据时为 None。
|
||
histogram: 按延迟区间分组的计数 dict
|
||
(key 为 "0-100ms" / "100-500ms" / "500ms-1s" / "1-5s" / "5s+")。
|
||
"""
|
||
|
||
p50_ms: float | None
|
||
p90_ms: float | None
|
||
p99_ms: float | None
|
||
max_ms: float | None
|
||
histogram: dict[str, int]
|
||
|
||
def to_dict(self) -> dict[str, Any]:
|
||
"""序列化为 dict,分位数字段为 None 时返回 None。"""
|
||
return {
|
||
"p50_ms": self.p50_ms,
|
||
"p90_ms": self.p90_ms,
|
||
"p99_ms": self.p99_ms,
|
||
"max_ms": self.max_ms,
|
||
"histogram": self.histogram,
|
||
}
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class DeliveryFunnel:
|
||
"""投递漏斗分析结果(ANL-07)。
|
||
|
||
描述投递管道各节点的条目数与相对 enter 的转化率,由 ``getDeliveryFunnel``
|
||
返回,供运维排障定位投递链路损耗节点。
|
||
|
||
字段:
|
||
enter: 进入投递管道的条目数。
|
||
sent: 投递成功数。
|
||
suppressed: 被抑制数。
|
||
failed: 失败数。
|
||
dead: 死信数。
|
||
conversion_rate: 各节点相对 enter 的转化率 dict
|
||
(key 为 "sent" / "suppressed" / "failed" / "dead",value 为 0-100)。
|
||
"""
|
||
|
||
enter: int
|
||
sent: int
|
||
suppressed: int
|
||
failed: int
|
||
dead: int
|
||
conversion_rate: dict[str, float]
|
||
|
||
def to_dict(self) -> dict[str, Any]:
|
||
"""序列化为 dict,供控制面结果返回与日志输出使用。"""
|
||
return {
|
||
"enter": self.enter,
|
||
"sent": self.sent,
|
||
"suppressed": self.suppressed,
|
||
"failed": self.failed,
|
||
"dead": self.dead,
|
||
"conversion_rate": self.conversion_rate,
|
||
}
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class AccountAnalyticsQuery:
|
||
"""账户活跃度分析查询(ANL-ACCOUNTS)。
|
||
|
||
描述账户活跃度分析查询条件,按时间范围(必填)与渠道类型过滤,
|
||
支持按粒度切片。
|
||
|
||
字段:
|
||
start_time: 起始时间(必填)。
|
||
end_time: 截止时间(必填)。
|
||
granularity: 时间粒度(默认 day,可选 hour/day/week)。
|
||
channel_type: 渠道类型过滤(可选)。
|
||
"""
|
||
|
||
start_time: datetime
|
||
end_time: datetime
|
||
granularity: Literal["hour", "day", "week"] = "day"
|
||
channel_type: ChannelType | None = None
|
||
|
||
def __post_init__(self) -> None:
|
||
"""校验时间范围与粒度取值。
|
||
|
||
``start_time`` 必须早于 ``end_time``,``granularity`` 必须为
|
||
``hour`` / ``day`` / ``week`` 之一,在构造时即抛出
|
||
``ValidationError``,adapter 不再做该校验(INV-8)。
|
||
"""
|
||
if self.start_time >= self.end_time:
|
||
raise ValidationError(
|
||
"time_range",
|
||
"start_time must be earlier than end_time",
|
||
)
|
||
if self.granularity not in ("hour", "day", "week"):
|
||
raise ValidationError(
|
||
"granularity",
|
||
"must be one of: hour, day, week",
|
||
)
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class AccountActivityStat:
|
||
"""账户活跃统计(ANL-ACCOUNTS by_account 条目)。
|
||
|
||
描述单账户在时间范围内的活跃度指标,由 MessageRepositoryPort 与
|
||
ChannelSessionRepositoryPort 聚合计算。
|
||
|
||
字段:
|
||
account_id: 账户 ID。
|
||
channel_type: 渠道类型字符串值。
|
||
message_count: 消息总数。
|
||
session_count: 会话总数。
|
||
active_days: 活跃天数。
|
||
avg_daily_messages: 日均消息数。
|
||
"""
|
||
|
||
account_id: str
|
||
channel_type: str
|
||
message_count: int
|
||
session_count: int
|
||
active_days: int
|
||
avg_daily_messages: float
|
||
|
||
def to_dict(self) -> dict[str, Any]:
|
||
"""序列化为 dict,供控制面结果返回与日志输出使用。"""
|
||
return {
|
||
"account_id": self.account_id,
|
||
"channel_type": self.channel_type,
|
||
"message_count": self.message_count,
|
||
"session_count": self.session_count,
|
||
"active_days": self.active_days,
|
||
"avg_daily_messages": self.avg_daily_messages,
|
||
}
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class AccountAnalyticsResult:
|
||
"""账户活跃度分析结果(ANL-ACCOUNTS)。
|
||
|
||
描述账户活跃度分析聚合结果,包含按账户切片的统计与活跃账户数趋势。
|
||
``trend`` 复用通用 ``TrendDataPoint``,``value`` 承载该时间桶的活跃账户数,
|
||
序列化时由 ``to_dict`` 映射为 ``active_accounts`` 字段名;``timestamp``
|
||
为 ``datetime``,序列化为 ISO 8601 UTC 字符串。
|
||
|
||
字段:
|
||
by_account: 按账户切片的统计元组。
|
||
trend: 活跃账户数趋势元组(value=active_accounts)。
|
||
"""
|
||
|
||
by_account: tuple[AccountActivityStat, ...]
|
||
trend: tuple[TrendDataPoint, ...]
|
||
|
||
def to_dict(self) -> dict[str, Any]:
|
||
"""序列化为 dict。
|
||
|
||
- ``by_account`` 递归调用 ``AccountActivityStat.to_dict``。
|
||
- ``trend`` 将 ``TrendDataPoint.timestamp``(datetime)格式化为
|
||
ISO 8601 UTC 字符串,并将 ``value`` 映射为 ``active_accounts`` 字段名。
|
||
"""
|
||
return {
|
||
"by_account": [stat.to_dict() for stat in self.by_account],
|
||
"trend": [
|
||
{
|
||
"timestamp": format_utc_datetime(point.timestamp),
|
||
"active_accounts": point.value,
|
||
}
|
||
for point in self.trend
|
||
],
|
||
}
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class PeerAnalyticsQuery:
|
||
"""对端活跃度分析查询(ANL-PEERS)。
|
||
|
||
描述对端活跃度分析查询条件,按时间范围(必填)与渠道类型/账户过滤,
|
||
返回按消息数降序的 Top N 对端。
|
||
|
||
字段:
|
||
start_time: 起始时间(必填)。
|
||
end_time: 截止时间(必填)。
|
||
channel_type: 渠道类型过滤(可选)。
|
||
account_id: 账户 ID 过滤(可选)。
|
||
limit: Top N 上限(默认 100,1-500)。
|
||
"""
|
||
|
||
start_time: datetime
|
||
end_time: datetime
|
||
channel_type: ChannelType | None = None
|
||
account_id: str | None = None
|
||
limit: int = 100
|
||
|
||
def __post_init__(self) -> None:
|
||
"""校验时间范围与 limit 取值。
|
||
|
||
``start_time`` 必须早于 ``end_time``,``limit`` 必须在 1-500 之间,
|
||
在构造时即抛出 ``ValidationError``,adapter 不再做该校验(INV-8)。
|
||
"""
|
||
if self.start_time >= self.end_time:
|
||
raise ValidationError(
|
||
"time_range",
|
||
"start_time must be earlier than end_time",
|
||
)
|
||
if self.limit < 1 or self.limit > 500:
|
||
raise ValidationError(
|
||
"limit",
|
||
"limit must be in [1, 500]",
|
||
)
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class PeerActivityStat:
|
||
"""对端活跃统计(ANL-PEERS top_active 条目)。
|
||
|
||
描述单对端在时间范围内的活跃度指标,由 MessageRepositoryPort 聚合计算。
|
||
|
||
字段:
|
||
peer_id: 对端 ID。
|
||
channel_type: 渠道类型字符串值。
|
||
message_count: 消息总数。
|
||
session_count: 会话总数。
|
||
first_seen: 首次出现时间(可选,无数据时为 None)。
|
||
last_seen: 最后出现时间(可选,无数据时为 None)。
|
||
"""
|
||
|
||
peer_id: str
|
||
channel_type: str
|
||
message_count: int
|
||
session_count: int
|
||
first_seen: datetime | None
|
||
last_seen: datetime | None
|
||
|
||
def to_dict(self) -> dict[str, Any]:
|
||
"""序列化为 dict。
|
||
|
||
``first_seen`` / ``last_seen`` 为 ``datetime``,序列化为 ISO 8601 UTC
|
||
字符串;为 ``None`` 时原样返回 ``None``。
|
||
"""
|
||
return {
|
||
"peer_id": self.peer_id,
|
||
"channel_type": self.channel_type,
|
||
"message_count": self.message_count,
|
||
"session_count": self.session_count,
|
||
"first_seen": format_utc_datetime(self.first_seen),
|
||
"last_seen": format_utc_datetime(self.last_seen),
|
||
}
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class PeerAnalyticsResult:
|
||
"""对端活跃度分析结果(ANL-PEERS)。
|
||
|
||
描述对端活跃度分析聚合结果,按 ``message_count`` 降序返回 Top N 对端。
|
||
|
||
字段:
|
||
top_active: 按消息数降序的对端统计元组。
|
||
"""
|
||
|
||
top_active: tuple[PeerActivityStat, ...]
|
||
|
||
def to_dict(self) -> dict[str, Any]:
|
||
"""序列化为 dict,递归调用 ``PeerActivityStat.to_dict``。"""
|
||
return {
|
||
"top_active": [stat.to_dict() for stat in self.top_active],
|
||
}
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class ContentReviewAnalyticsQuery:
|
||
"""内容审核分析查询(ANL-CR)。
|
||
|
||
描述内容审核分析查询条件,按时间范围(必填)与渠道类型过滤,
|
||
支持按粒度切片。
|
||
|
||
字段:
|
||
start_time: 起始时间(必填)。
|
||
end_time: 截止时间(必填)。
|
||
granularity: 时间粒度(默认 day,可选 hour/day/week)。
|
||
channel_type: 渠道类型过滤(可选)。
|
||
"""
|
||
|
||
start_time: datetime
|
||
end_time: datetime
|
||
granularity: Literal["hour", "day", "week"] = "day"
|
||
channel_type: ChannelType | None = None
|
||
|
||
def __post_init__(self) -> None:
|
||
"""校验时间范围与粒度取值。
|
||
|
||
``start_time`` 必须早于 ``end_time``,``granularity`` 必须为
|
||
``hour`` / ``day`` / ``week`` 之一,在构造时即抛出
|
||
``ValidationError``,adapter 不再做该校验(INV-8)。
|
||
"""
|
||
if self.start_time >= self.end_time:
|
||
raise ValidationError(
|
||
"time_range",
|
||
"start_time must be earlier than end_time",
|
||
)
|
||
if self.granularity not in ("hour", "day", "week"):
|
||
raise ValidationError(
|
||
"granularity",
|
||
"must be one of: hour, day, week",
|
||
)
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class ContentReviewAnalyticsResult:
|
||
"""内容审核分析结果(ANL-CR)。
|
||
|
||
描述内容审核分析聚合结果,包含总审核/拦截计数、拦截率、按分类切片与
|
||
审核趋势。``by_category`` 复用通用 ``CategoryStat``,序列化时由
|
||
``to_dict`` 按 ``count / total_reviews * 100`` 计算并补充 ``rate`` 字段;
|
||
``trend`` 为多值时间序列(``reviewed`` / ``blocked`` 双计数),无法用单一
|
||
``value`` 字段承载,故使用 ``dict[str, Any]`` 元组,由适配器层按
|
||
``{timestamp, reviewed, blocked}`` 结构组装,序列化时将 ``timestamp``
|
||
(datetime)格式化为 ISO 8601 UTC 字符串。
|
||
|
||
字段命名与 ``ContentReviewStatsResult`` 对齐:``total_reviews`` /
|
||
``block_count`` 共享同一命名约定(``total_X`` 表示总数,``X_count``
|
||
表示分类计数)。
|
||
|
||
字段:
|
||
total_reviews: 审核总数。
|
||
block_count: 拦截数。
|
||
block_rate: 拦截率(0-100)。
|
||
by_category: 按分类切片的统计元组(rate 由 to_dict 补充)。
|
||
trend: 审核趋势元组(每项含 timestamp/reviewed/blocked)。
|
||
"""
|
||
|
||
total_reviews: int
|
||
block_count: int
|
||
block_rate: float
|
||
by_category: tuple[CategoryStat, ...]
|
||
trend: tuple[dict[str, Any], ...]
|
||
|
||
def to_dict(self) -> dict[str, Any]:
|
||
"""序列化为 dict。
|
||
|
||
- ``by_category`` 递归展开 ``CategoryStat``,并按 ``count / total_reviews
|
||
* 100`` 计算补充 ``rate`` 字段(``total_reviews`` 为 0 时 ``rate=0.0``)。
|
||
- ``trend`` 将每项的 ``timestamp``(datetime)格式化为 ISO 8601 UTC 字符串,
|
||
保留 ``reviewed`` / ``blocked`` 双计数。
|
||
"""
|
||
return {
|
||
"total_reviews": self.total_reviews,
|
||
"block_count": self.block_count,
|
||
"block_rate": self.block_rate,
|
||
"by_category": [
|
||
{
|
||
"category": stat.category,
|
||
"count": stat.count,
|
||
"rate": (stat.count / self.total_reviews * 100) if self.total_reviews > 0 else 0.0,
|
||
}
|
||
for stat in self.by_category
|
||
],
|
||
"trend": [
|
||
{
|
||
"timestamp": format_utc_datetime(item["timestamp"]),
|
||
"reviewed": item["reviewed"],
|
||
"blocked": item["blocked"],
|
||
}
|
||
for item in self.trend
|
||
],
|
||
}
|
||
|
||
|
||
__all__ = [
|
||
"AccountActivityStat",
|
||
"AccountAnalyticsQuery",
|
||
"AccountAnalyticsResult",
|
||
"ContentReviewAnalyticsQuery",
|
||
"ContentReviewAnalyticsResult",
|
||
"DeliveryAnalytics",
|
||
"DeliveryFunnel",
|
||
"DeliveryLatencyDistribution",
|
||
"MessageAnalytics",
|
||
"MessageDistribution",
|
||
"PeerActivityStat",
|
||
"PeerAnalyticsQuery",
|
||
"PeerAnalyticsResult",
|
||
"SessionAnalytics",
|
||
"TimeSeriesPoint",
|
||
]
|