refactor: 批量清理冗余空行,优化部分枚举使用方式

1.  移除所有适配器文件中多余的空导入行
2.  调整ValidationError继承,移除不必要的ValueError继承
3.  修正多处ChannelType使用方式,从.value改为直接使用枚举实例
4.  优化飞书插件部分硬编码渠道类型为枚举实例
5.  更新wechat_ilink插件清单与适配器配置
6.  新增飞书目录适配器缓存清理支持判断与iLink生命周期适配器凭据轮换支持判断
7.  优化配置处理器历史查询逻辑,区分键不存在与无历史记录场景
This commit is contained in:
Kris 2026-07-04 00:14:56 +08:00
parent 29a45916ff
commit 8eead29de0
145 changed files with 992 additions and 725 deletions

View File

@ -44,22 +44,22 @@ from .agent_run_adapter import AgentRunAdapter
from .agent_run_execution_adapter import AgentRunExecutionAdapter
from .arq_queue_adapter import ARQQueueAdapter
from .channel_persistence_adapter import ChannelPersistenceAdapter
from .conversation_adapter import ConversationAdapter
from .identity_resolver_adapter import IdentityResolverAdapter
from .masking_adapter import MaskingAdapter
from .opentelemetry_tracer_adapter import OpenTelemetryTracerAdapter
from .redis_cache_adapter import RedisCacheAdapter
from .redis_config_adapter import RedisConfigAdapter
from .service_account_adapter import ServiceAccountAdapter
from .sqlalchemy_transaction_adapter import SqlAlchemyTransactionAdapter
from .structured_logger_adapter import StructuredLoggerAdapter
# 独立装配路径适配器5 个,不经过工厂,不进入 DrivenAdapters 聚合)
from .content_review_repository_adapter import ContentReviewRepositoryAdapter
from .conversation_adapter import ConversationAdapter
from .default_content_moderation_adapter import DefaultContentModerationAdapter
from .identity_resolver_adapter import IdentityResolverAdapter
from .inmemory_realtime_metrics_adapter import InmemoryRealtimeMetricsAdapter
from .masking_adapter import MaskingAdapter
from .message_ops_port_adapter import MessageOpsPortAdapter
from .opentelemetry_tracer_adapter import OpenTelemetryTracerAdapter
from .redis_cache_adapter import RedisCacheAdapter
from .redis_config_adapter import RedisConfigAdapter
from .report_repository_adapter import SqlAlchemyReportRepositoryAdapter
from .service_account_adapter import ServiceAccountAdapter
from .sqlalchemy_transaction_adapter import SqlAlchemyTransactionAdapter
from .structured_logger_adapter import StructuredLoggerAdapter
# 无状态适配器全局实例Masking / Logger / Tracer跨请求复用避免重复构造。
# Masking 无状态无依赖最先构造Logger 注入 Masking 以便日志 kwargs 自动脱敏;

View File

@ -163,7 +163,7 @@ class AgentRunAdapter(AgentRunPort):
await self._logger.error(
"service_account_resolve_failed",
resource="service_account",
channel_type=ctx.channel_type.value,
channel_type=ctx.channel_type,
account_id=ctx.channel_account_id,
error=str(exc),
)
@ -177,7 +177,7 @@ class AgentRunAdapter(AgentRunPort):
current_uid = ""
if ctx is not None:
if ctx.channel_type is not None:
meta["channel_type"] = ctx.channel_type.value
meta["channel_type"] = ctx.channel_type
meta["channel_account_id"] = ctx.channel_account_id
meta["channel_peer_id"] = ctx.channel_peer_id
meta["channel_group_id"] = ctx.channel_group_id

View File

@ -306,7 +306,7 @@ class ChannelPersistenceAdapter(
"""
commit = self._should_commit(tx)
data: dict[str, Any] = {
"channel_type": cmd.channel_type.value,
"channel_type": cmd.channel_type,
"account_id": cmd.account_id,
"display_name": cmd.display_name,
"config": cmd.config,
@ -359,7 +359,7 @@ class ChannelPersistenceAdapter(
"""
commit = self._should_commit(tx)
try:
orm = await self._repos.account.get_by_type_and_account(cmd.channel_type.value, cmd.account_id)
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 = {}
@ -411,7 +411,7 @@ class ChannelPersistenceAdapter(
DependencyError: 数据库故障
"""
try:
orm = await self._repos.account.get_by_type_and_account(channel_type.value, account_id)
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)
@ -446,7 +446,7 @@ class ChannelPersistenceAdapter(
"""
try:
orms = await self._repos.account.list(
channel_type=channel_type.value if channel_type else None,
channel_type=channel_type if channel_type else None,
limit=limit,
offset=offset,
)
@ -486,7 +486,7 @@ class ChannelPersistenceAdapter(
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.value)
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)
@ -528,7 +528,7 @@ class ChannelPersistenceAdapter(
"""
commit = self._should_commit(tx)
try:
orm = await self._repos.account.get_by_type_and_account(channel_type.value, account_id)
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)
@ -563,7 +563,7 @@ class ChannelPersistenceAdapter(
"""
commit = self._should_commit(tx)
try:
account_orm = await self._repos.account.get_by_type_and_account(cmd.channel_type.value, cmd.account_id)
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
@ -575,7 +575,7 @@ class ChannelPersistenceAdapter(
data = {
"session_id": uuid.uuid4().hex,
"account_id": account_orm.id,
"channel_type": cmd.channel_type.value,
"channel_type": cmd.channel_type,
"peer_id": cmd.peer_id,
"chat_type": cmd.chat_type,
"conversation_id": conversation_id_int,
@ -703,7 +703,7 @@ class ChannelPersistenceAdapter(
DependencyError: 数据库故障
"""
try:
account_orm = await self._repos.account.get_by_type_and_account(channel_type.value, account_id)
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)
@ -837,7 +837,7 @@ class ChannelPersistenceAdapter(
"""
try:
orms = await self._repos.session.list(
channel_type=channel_type.value if channel_type else None,
channel_type=channel_type if channel_type else None,
peer_id=peer_id,
start_time=created_after,
end_time=created_before,
@ -889,7 +889,7 @@ class ChannelPersistenceAdapter(
"""
try:
return await self._repos.session.count(
channel_type=channel_type.value if channel_type else None,
channel_type=channel_type if channel_type else None,
peer_id=peer_id,
start_time=created_after,
end_time=created_before,
@ -938,7 +938,7 @@ class ChannelPersistenceAdapter(
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.value)
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:
@ -1117,7 +1117,7 @@ class ChannelPersistenceAdapter(
"""
commit = self._should_commit(tx)
try:
account_orm = await self._repos.account.get_by_type_and_account(cmd.channel_type.value, cmd.account_id)
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()
@ -1266,7 +1266,7 @@ class ChannelPersistenceAdapter(
DependencyError: 数据库故障fail-closed
"""
try:
account_orm = await self._repos.account.get_by_type_and_account(channel_type.value, account_id)
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)
@ -1343,9 +1343,7 @@ class ChannelPersistenceAdapter(
account_pks: list[int] | None = None
if query.channel_type is not None and query.account_id is not None:
# 按渠道类型 + 业务 account_id 定位单个账户
account_orm = await self._repos.account.get_by_type_and_account(
query.channel_type.value, query.account_id
)
account_orm = await self._repos.account.get_by_type_and_account(query.channel_type, query.account_id)
if account_orm is None:
return ()
account_pks = [account_orm.id]
@ -1362,7 +1360,7 @@ class ChannelPersistenceAdapter(
elif query.channel_type is not None:
# 仅按渠道类型过滤,查询该渠道下所有未删除账户
stmt = select(ChannelAccountORM.id).where(
ChannelAccountORM.channel_type == query.channel_type.value,
ChannelAccountORM.channel_type == query.channel_type,
ChannelAccountORM.is_deleted == 0,
)
result = await self._db.execute(stmt)
@ -1482,7 +1480,7 @@ class ChannelPersistenceAdapter(
agg_stmt = agg_stmt.join(
ChannelAccountORM,
ChannelPairingORM.account_id == ChannelAccountORM.id,
).where(ChannelAccountORM.channel_type == query.channel_type.value)
).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)
@ -1515,7 +1513,7 @@ class ChannelPersistenceAdapter(
trend_stmt = trend_stmt.join(
ChannelAccountORM,
ChannelPairingORM.account_id == ChannelAccountORM.id,
).where(ChannelAccountORM.channel_type == query.channel_type.value)
).where(ChannelAccountORM.channel_type == query.channel_type)
trend_result = await self._db.execute(trend_stmt)
trend: list[PairingTrendPoint] = [
PairingTrendPoint(
@ -1679,7 +1677,7 @@ class ChannelPersistenceAdapter(
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.value if query.target_channel is not None else None,
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),
@ -1728,7 +1726,7 @@ class ChannelPersistenceAdapter(
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.value if query.target_channel is not None else None,
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),
@ -1843,7 +1841,7 @@ class ChannelPersistenceAdapter(
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.value if query.target_channel is not None else None,
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),
@ -2204,7 +2202,7 @@ class ChannelPersistenceAdapter(
raise NotFoundError("message", str(message_pk))
return RetryContext(
channel_type=account_dto.channel_type.value,
channel_type=account_dto.channel_type,
account_id=account_dto.account_id,
peer_id=peer_id,
message_content=message_orm.content,
@ -2292,7 +2290,7 @@ class ChannelPersistenceAdapter(
ChannelOutboxEntryORM.account_id == ChannelAccountORM.id,
)
if query_filter.channel_type is not None:
stmt = stmt.where(ChannelAccountORM.channel_type == query_filter.channel_type.value)
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.status is not None:
@ -2456,7 +2454,7 @@ class ChannelPersistenceAdapter(
ChannelAccountORM,
ChannelOutboxEntryORM.account_id == ChannelAccountORM.id,
)
stmt = stmt.where(ChannelAccountORM.channel_type == channel_type.value)
stmt = stmt.where(ChannelAccountORM.channel_type == channel_type)
result = await self._db.execute(stmt)
status_counts: dict[str, int] = {
"pending": 0,
@ -2518,7 +2516,7 @@ class ChannelPersistenceAdapter(
stmt = stmt.join(
ChannelAccountORM,
ChannelOutboxEntryORM.account_id == ChannelAccountORM.id,
).where(ChannelAccountORM.channel_type == query.channel_type.value)
).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:
@ -2610,7 +2608,7 @@ class ChannelPersistenceAdapter(
stmt = stmt.join(
ChannelAccountORM,
ChannelOutboxEntryORM.account_id == ChannelAccountORM.id,
).where(ChannelAccountORM.channel_type == query.channel_type.value)
).where(ChannelAccountORM.channel_type == query.channel_type)
result = await self._db.execute(stmt)
return tuple(
TrendDataPoint(
@ -2652,7 +2650,7 @@ class ChannelPersistenceAdapter(
.group_by(ChannelAccountORM.channel_type, ChannelAccountORM.status)
)
if channel_type is not None:
stmt = stmt.where(ChannelAccountORM.channel_type == channel_type.value)
stmt = stmt.where(ChannelAccountORM.channel_type == channel_type)
result = await self._db.execute(stmt)
by_channel: dict[str, int] = {}
@ -2699,7 +2697,7 @@ class ChannelPersistenceAdapter(
.group_by(ChannelSessionORM.channel_type, ChannelSessionORM.is_temporary)
)
if channel_type is not None:
stmt = stmt.where(ChannelSessionORM.channel_type == channel_type.value)
stmt = stmt.where(ChannelSessionORM.channel_type == channel_type)
result = await self._db.execute(stmt)
by_channel: dict[str, int] = {}
@ -2895,7 +2893,7 @@ class ChannelPersistenceAdapter(
"identity_id": uuid.uuid4().hex,
"identity_type": cmd.identity_type,
"identity_value": cmd.identity_value,
"channel_type": cmd.channel_type.value if cmd.channel_type is not None else None,
"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,
@ -3209,7 +3207,7 @@ class ChannelPersistenceAdapter(
total_stmt = total_stmt.join(
ConversationORM,
ConversationORM.id == MessageORM.conversation_id,
).where(ConversationORM.channel_type == channel_type.value)
).where(ConversationORM.channel_type == channel_type)
total = int(await self._db.scalar(total_stmt) or 0)
# 时间序列
@ -3227,7 +3225,7 @@ class ChannelPersistenceAdapter(
ts_stmt = ts_stmt.join(
ConversationORM,
ConversationORM.id == MessageORM.conversation_id,
).where(ConversationORM.channel_type == channel_type.value)
).where(ConversationORM.channel_type == channel_type)
ts_result = await self._db.execute(ts_stmt)
timeseries = tuple(
TimeSeriesPoint(
@ -3260,7 +3258,7 @@ class ChannelPersistenceAdapter(
)
)
if channel_type is not None:
combined_stmt = combined_stmt.where(ConversationORM.channel_type == channel_type.value)
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] = {}
@ -3323,7 +3321,7 @@ class ChannelPersistenceAdapter(
total_stmt = total_stmt.join(
ConversationORM,
ConversationORM.id == MessageORM.conversation_id,
).where(ConversationORM.channel_type == channel_type.value)
).where(ConversationORM.channel_type == channel_type)
total = int(await self._db.scalar(total_stmt) or 0)
# 按消息类型分组
@ -3339,7 +3337,7 @@ class ChannelPersistenceAdapter(
type_stmt = type_stmt.join(
ConversationORM,
ConversationORM.id == MessageORM.conversation_id,
).where(ConversationORM.channel_type == channel_type.value)
).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():
@ -3394,7 +3392,7 @@ class ChannelPersistenceAdapter(
ChannelSessionORM.is_deleted == 0,
)
if channel_type is not None:
total_stmt = total_stmt.where(ChannelSessionORM.channel_type == channel_type.value)
total_stmt = total_stmt.where(ChannelSessionORM.channel_type == channel_type)
total = int(await self._db.scalar(total_stmt) or 0)
# 时间序列
@ -3410,7 +3408,7 @@ class ChannelPersistenceAdapter(
.order_by(bucket_expr)
)
if channel_type is not None:
ts_stmt = ts_stmt.where(ChannelSessionORM.channel_type == channel_type.value)
ts_stmt = ts_stmt.where(ChannelSessionORM.channel_type == channel_type)
ts_result = await self._db.execute(ts_stmt)
timeseries = tuple(
TimeSeriesPoint(
@ -3438,7 +3436,7 @@ class ChannelPersistenceAdapter(
)
)
if channel_type is not None:
combined_stmt = combined_stmt.where(ChannelSessionORM.channel_type == channel_type.value)
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}
@ -3480,7 +3478,7 @@ class ChannelPersistenceAdapter(
.group_by(mc_bucket)
)
if channel_type is not None:
mc_stmt = mc_stmt.where(ChannelSessionORM.channel_type == channel_type.value)
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,
@ -3561,7 +3559,7 @@ class ChannelPersistenceAdapter(
agg_stmt = agg_stmt.join(
ChannelAccountORM,
ChannelOutboxEntryORM.account_id == ChannelAccountORM.id,
).where(ChannelAccountORM.channel_type == channel_type.value)
).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)
@ -3594,7 +3592,7 @@ class ChannelPersistenceAdapter(
retry_stmt = retry_stmt.join(
ChannelAccountORM,
ChannelOutboxEntryORM.account_id == ChannelAccountORM.id,
).where(ChannelAccountORM.channel_type == channel_type.value)
).where(ChannelAccountORM.channel_type == channel_type)
retry_result = await self._db.execute(retry_stmt)
retry_distribution: dict[str, int] = {
"0": 0,
@ -3662,7 +3660,7 @@ class ChannelPersistenceAdapter(
pct_stmt = pct_stmt.join(
ChannelAccountORM,
ChannelOutboxEntryORM.account_id == ChannelAccountORM.id,
).where(ChannelAccountORM.channel_type == channel_type.value)
).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
@ -3691,7 +3689,7 @@ class ChannelPersistenceAdapter(
hist_stmt = hist_stmt.join(
ChannelAccountORM,
ChannelOutboxEntryORM.account_id == ChannelAccountORM.id,
).where(ChannelAccountORM.channel_type == channel_type.value)
).where(ChannelAccountORM.channel_type == channel_type)
hist_result = await self._db.execute(hist_stmt)
histogram: dict[str, int] = {
"0-100ms": 0,
@ -3783,7 +3781,7 @@ class ChannelPersistenceAdapter(
agg_stmt = agg_stmt.join(
ChannelAccountORM,
ChannelOutboxEntryORM.account_id == ChannelAccountORM.id,
).where(ChannelAccountORM.channel_type == query.channel_type.value)
).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:
@ -3897,7 +3895,7 @@ class ChannelPersistenceAdapter(
stmt = stmt.join(
ChannelAccountORM,
ChannelOutboxEntryORM.account_id == ChannelAccountORM.id,
).where(ChannelAccountORM.channel_type == channel_type.value)
).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():
@ -3977,7 +3975,7 @@ class ChannelPersistenceAdapter(
)
)
if query.channel_type is not None:
msg_stmt = msg_stmt.where(ConversationORM.channel_type == query.channel_type.value)
msg_stmt = msg_stmt.where(ConversationORM.channel_type == query.channel_type)
msg_result = await self._db.execute(msg_stmt)
# 会话维度聚合JOIN ChannelSession → ChannelAccount按业务 account_id 分组
@ -4002,7 +4000,7 @@ class ChannelPersistenceAdapter(
)
)
if query.channel_type is not None:
session_stmt = session_stmt.where(ChannelAccountORM.channel_type == query.channel_type.value)
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():
@ -4077,7 +4075,7 @@ class ChannelPersistenceAdapter(
.order_by(bucket_expr)
)
if query.channel_type is not None:
stmt = stmt.where(ConversationORM.channel_type == query.channel_type.value)
stmt = stmt.where(ConversationORM.channel_type == query.channel_type)
result = await self._db.execute(stmt)
return tuple(
TrendDataPoint(
@ -4151,7 +4149,7 @@ class ChannelPersistenceAdapter(
.limit(query.limit)
)
if query.channel_type is not None:
stmt = stmt.where(ChannelSessionORM.channel_type == query.channel_type.value)
stmt = stmt.where(ChannelSessionORM.channel_type == query.channel_type)
if query.account_id is not None:
stmt = stmt.join(
ChannelAccountORM,

View File

@ -140,7 +140,7 @@ def _record_to_orm_data(record: ContentReviewRecord) -> dict[str, Any]:
"""
return {
"review_id": record.review_id,
"channel_type": record.channel_type.value,
"channel_type": record.channel_type,
"account_id": record.account_id,
"resource_type": record.resource_type.value,
"content_preview": record.content_preview,
@ -163,7 +163,7 @@ def _orm_to_history_item(orm: Any) -> ContentReviewHistoryItem:
"""
return ContentReviewHistoryItem(
review_id=orm.review_id,
channel_type=_enum(orm.channel_type, ChannelType, "content_review_record"),
channel_type=ChannelType(orm.channel_type),
account_id=orm.account_id,
resource_type=_enum(orm.resource_type, ContentReviewResourceType, "content_review_record"),
verdict=_enum(orm.verdict, ContentReviewVerdict, "content_review_record"),
@ -184,7 +184,7 @@ def _orm_to_detail(orm: Any) -> ContentReviewDetail:
resource = "content_review_record"
return ContentReviewDetail(
review_id=orm.review_id,
channel_type=_enum(orm.channel_type, ChannelType, resource),
channel_type=ChannelType(orm.channel_type),
account_id=orm.account_id,
resource_type=_enum(orm.resource_type, ContentReviewResourceType, resource),
content_preview=orm.content_preview,
@ -324,7 +324,7 @@ class ContentReviewRepositoryAdapter(ContentReviewRepositoryPort):
"""
try:
orms = await self._repo.list(
channel_type=filter.channel_type.value if filter.channel_type else None,
channel_type=filter.channel_type if filter.channel_type else None,
account_id=filter.account_id,
verdict=filter.verdict.value if filter.verdict else None,
start_time=_to_naive_utc(filter.start_time),
@ -364,7 +364,7 @@ class ContentReviewRepositoryAdapter(ContentReviewRepositoryPort):
"""
try:
return await self._repo.count(
channel_type=filter.channel_type.value if filter.channel_type else None,
channel_type=filter.channel_type if filter.channel_type else None,
account_id=filter.account_id,
verdict=filter.verdict.value if filter.verdict else None,
start_time=_to_naive_utc(filter.start_time),
@ -446,7 +446,7 @@ class ContentReviewRepositoryAdapter(ContentReviewRepositoryPort):
data = await self._repo.query_analytics(
start_time=_to_naive_utc(query.start_time),
end_time=_to_naive_utc(query.end_time),
channel_type=query.channel_type.value if query.channel_type else None,
channel_type=query.channel_type if query.channel_type else None,
granularity=query.granularity,
)
return ContentReviewAnalyticsResult(
@ -496,7 +496,7 @@ class ContentReviewRepositoryAdapter(ContentReviewRepositoryPort):
"""
try:
data = await self._repo.query_stats(
channel_type=query.channel_type.value if query.channel_type else None,
channel_type=query.channel_type if query.channel_type else None,
account_id=query.account_id,
start_time=_to_naive_utc(query.start_time),
end_time=_to_naive_utc(query.end_time),

View File

@ -366,7 +366,7 @@ class ConversationAdapter(ConversationPort):
stmt = stmt.join(
ConversationORM,
ConversationORM.id == MessageORM.conversation_id,
).where(ConversationORM.channel_type == channel_type.value)
).where(ConversationORM.channel_type == channel_type)
if start_time is not None:
stmt = stmt.where(MessageORM.created_at >= start_time)
if end_time is not None:
@ -425,7 +425,7 @@ class ConversationAdapter(ConversationPort):
stmt = stmt.join(
ConversationORM,
ConversationORM.id == MessageORM.conversation_id,
).where(ConversationORM.channel_type == channel_type.value)
).where(ConversationORM.channel_type == channel_type)
result = await self._db.execute(stmt)
orm = result.scalar_one_or_none()
if orm is None:
@ -489,7 +489,7 @@ class ConversationAdapter(ConversationPort):
stmt = stmt.join(
ConversationORM,
ConversationORM.id == MessageORM.conversation_id,
).where(ConversationORM.channel_type == channel_type.value)
).where(ConversationORM.channel_type == channel_type)
if start_time is not None:
stmt = stmt.where(MessageORM.created_at >= start_time)
if end_time is not None:
@ -549,7 +549,7 @@ class ConversationAdapter(ConversationPort):
try:
account_orm = await self._db.execute(
select(ChannelAccountORM.id).where(
ChannelAccountORM.channel_type == cmd.channel_type.value,
ChannelAccountORM.channel_type == cmd.channel_type,
ChannelAccountORM.account_id == cmd.account_id,
ChannelAccountORM.is_deleted == 0,
)
@ -560,7 +560,7 @@ class ConversationAdapter(ConversationPort):
stmt = (
select(ChannelSessionORM)
.where(ChannelSessionORM.peer_id == cmd.peer_id)
.where(ChannelSessionORM.channel_type == cmd.channel_type.value)
.where(ChannelSessionORM.channel_type == cmd.channel_type)
.where(ChannelSessionORM.account_id == account_id_int)
.where(ChannelSessionORM.is_deleted == 0)
.order_by(ChannelSessionORM.created_at.desc())
@ -587,7 +587,7 @@ class ConversationAdapter(ConversationPort):
if not cmd.create_if_not_found:
raise NotFoundError(
"conversation",
f"{cmd.peer_id}@{cmd.channel_type.value}",
f"{cmd.peer_id}@{cmd.channel_type}",
)
conversation = ConversationORM(
thread_id=str(uuid.uuid4()),
@ -597,7 +597,7 @@ class ConversationAdapter(ConversationPort):
status=cmd.new_conversation_status,
extra_metadata=cmd.new_conversation_extra_metadata,
unified_identity_id=cmd.unified_identity_id,
channel_type=cmd.channel_type.value,
channel_type=cmd.channel_type,
channel_account_id=cmd.account_id,
)
self._db.add(conversation)
@ -1149,18 +1149,21 @@ class ConversationAdapter(ConversationPort):
DependencyError: 数据库故障时抛出
"""
try:
# DB 时间列为 naive UTC剥离 DTO 入参的 tzinfo见 _to_naive_utc 说明)。
start_naive = _to_naive_utc(start_time)
end_naive = _to_naive_utc(end_time)
stmt = select(MessageORM).options(selectinload(MessageORM.conversation))
if channel_type is not None:
stmt = stmt.join(
ConversationORM,
ConversationORM.id == MessageORM.conversation_id,
).where(ConversationORM.channel_type == channel_type.value)
).where(ConversationORM.channel_type == channel_type)
if role is not None:
stmt = stmt.where(MessageORM.role == role)
if start_time is not None:
stmt = stmt.where(MessageORM.created_at >= start_time)
if end_time is not None:
stmt = stmt.where(MessageORM.created_at <= end_time)
if start_naive is not None:
stmt = stmt.where(MessageORM.created_at >= start_naive)
if end_naive is not None:
stmt = stmt.where(MessageORM.created_at <= end_naive)
if channel_status is not None:
stmt = stmt.where(MessageORM.channel_status == channel_status)
stmt = stmt.order_by(MessageORM.created_at.desc()).limit(limit).offset(offset)
@ -1228,6 +1231,9 @@ class ConversationAdapter(ConversationPort):
DependencyError: 数据库故障时抛出
"""
try:
# DB 时间列为 naive UTC剥离 DTO 入参的 tzinfo见 _to_naive_utc 说明)。
start_naive = _to_naive_utc(start_time)
end_naive = _to_naive_utc(end_time)
stmt = (
select(func.count())
.select_from(MessageORM)
@ -1239,11 +1245,11 @@ class ConversationAdapter(ConversationPort):
if role is not None:
stmt = stmt.where(MessageORM.role == role)
if channel_type is not None:
stmt = stmt.where(ConversationORM.channel_type == channel_type.value)
if start_time is not None:
stmt = stmt.where(MessageORM.created_at >= start_time)
if end_time is not None:
stmt = stmt.where(MessageORM.created_at <= end_time)
stmt = stmt.where(ConversationORM.channel_type == channel_type)
if start_naive is not None:
stmt = stmt.where(MessageORM.created_at >= start_naive)
if end_naive is not None:
stmt = stmt.where(MessageORM.created_at <= end_naive)
if channel_status is not None:
stmt = stmt.where(MessageORM.channel_status == channel_status)
result = await self._db.execute(stmt)
@ -1302,7 +1308,7 @@ class ConversationAdapter(ConversationPort):
if end_naive is not None:
stmt = stmt.where(MessageORM.created_at <= end_naive)
if channel_type is not None:
stmt = stmt.where(ConversationORM.channel_type == channel_type.value)
stmt = stmt.where(ConversationORM.channel_type == channel_type)
result = await self._db.execute(stmt)
by_channel: dict[str, int] = {}
@ -1441,7 +1447,7 @@ class ConversationAdapter(ConversationPort):
stmt = stmt.join(
ConversationORM,
ConversationORM.id == MessageORM.conversation_id,
).where(ConversationORM.channel_type == channel_type.value)
).where(ConversationORM.channel_type == channel_type)
if start_time is not None:
stmt = stmt.where(MessageORM.created_at >= start_time)
if end_time is not None:
@ -1461,7 +1467,7 @@ class ConversationAdapter(ConversationPort):
channel_session_id=session_id_val,
channel_type=ChannelType(orm.conversation.channel_type)
if orm.conversation and orm.conversation.channel_type
else ChannelType.CUSTOM,
else ChannelType(""),
role=orm.role,
content=orm.content,
created_at=orm.created_at,
@ -1507,7 +1513,7 @@ class ConversationAdapter(ConversationPort):
stmt = stmt.join(
ConversationORM,
ConversationORM.id == MessageORM.conversation_id,
).where(ConversationORM.channel_type == channel_type.value)
).where(ConversationORM.channel_type == channel_type)
if start_time is not None:
stmt = stmt.where(MessageORM.created_at >= start_time)
if end_time is not None:

View File

@ -100,7 +100,7 @@ def orm_to_channel_account(orm: ChannelAccountORM) -> ChannelAccount:
) from exc
onboarding_status_raw = orm.onboarding_status or OnboardingStatus.PENDING.value
return ChannelAccount(
channel_type=_enum(orm.channel_type, ChannelType, "channel_account"),
channel_type=ChannelType(orm.channel_type),
account_id=orm.account_id,
display_name=orm.display_name,
config=config,
@ -147,7 +147,7 @@ def orm_to_channel_session(orm: ChannelSessionORM) -> ChannelSession:
"""
return ChannelSession(
session_id=orm.session_id,
channel_type=_enum(orm.channel_type, ChannelType, "channel_session"),
channel_type=ChannelType(orm.channel_type),
account_id=str(orm.account_id),
peer_id=orm.peer_id,
chat_type=orm.chat_type,
@ -191,9 +191,7 @@ def orm_to_pairing(
channel_account_id=(account_orm.account_id if account_orm is not None else str(orm.account_id)),
peer_id=orm.peer_id,
status=_enum(orm.status, PairingStatus, "pairing"),
channel_type=(
_enum(account_orm.channel_type, ChannelType, "channel_account") if account_orm is not None else None
),
channel_type=(ChannelType(account_orm.channel_type) if account_orm is not None else None),
peer_name=orm.peer_name,
approver_id=orm.approver_id,
approved_at=orm.approved_at,
@ -307,7 +305,7 @@ def orm_to_user_identity(orm: ChannelUserIdentityORM) -> UserIdentity:
user_id=str(orm.user_id) if orm.user_id is not None else None,
identity_type=orm.identity_type,
identity_value=orm.identity_value,
channel_type=_enum(orm.channel_type, ChannelType, "user_identity") if orm.channel_type is not None else None,
channel_type=ChannelType(orm.channel_type) if orm.channel_type is not None else None,
channel_sender_id=orm.channel_sender_id,
source=orm.source,
created_at=orm.created_at,

View File

@ -104,5 +104,5 @@ class MessageOpsPortAdapter(MessageOpsPort):
"message_ops_adapter_not_registered",
channel_type=str(channel_type),
)
raise ChannelDegradedError(channel=channel_type.value)
raise ChannelDegradedError(channel=channel_type)
return await adapter.recallMessage(channel_msg_id)

View File

@ -336,7 +336,7 @@ class ServiceAccountAdapter(ServiceAccountPort, ServiceAccountRepositoryPort):
if existing is not None:
await self._logger.info(
"service account already exists, returning existing",
channel_type=cmd.channel_type.value,
channel_type=cmd.channel_type,
account_id=cmd.account_id,
uid=cmd.uid,
)
@ -345,13 +345,13 @@ class ServiceAccountAdapter(ServiceAccountPort, ServiceAccountRepositoryPort):
await self._logger.error(
"service account creation failed due to integrity error",
channel_type=cmd.channel_type.value,
channel_type=cmd.channel_type,
account_id=cmd.account_id,
error=str(exc),
)
raise ServiceAccountCreationError(
channel_type=cmd.channel_type.value,
channel_type=cmd.channel_type,
account_id=cmd.account_id,
reason=f"integrity constraint violated: {exc}",
cause=exc,
@ -359,7 +359,7 @@ class ServiceAccountAdapter(ServiceAccountPort, ServiceAccountRepositoryPort):
except SQLAlchemyError as exc:
raise ServiceAccountCreationError(
channel_type=cmd.channel_type.value,
channel_type=cmd.channel_type,
account_id=cmd.account_id,
reason=f"database error: {exc}",
cause=exc,
@ -370,7 +370,7 @@ class ServiceAccountAdapter(ServiceAccountPort, ServiceAccountRepositoryPort):
except Exception as exc:
raise ServiceAccountCreationError(
channel_type=cmd.channel_type.value,
channel_type=cmd.channel_type,
account_id=cmd.account_id,
reason=f"unexpected error: {exc}",
cause=exc,
@ -378,7 +378,7 @@ class ServiceAccountAdapter(ServiceAccountPort, ServiceAccountRepositoryPort):
await self._logger.info(
"service account created",
channel_type=cmd.channel_type.value,
channel_type=cmd.channel_type,
account_id=cmd.account_id,
uid=cmd.uid,
)
@ -409,7 +409,7 @@ class ServiceAccountAdapter(ServiceAccountPort, ServiceAccountRepositoryPort):
ChannelAccountORM.service_user_uid == UserORM.uid,
)
.where(
ChannelAccountORM.channel_type == channel_type.value,
ChannelAccountORM.channel_type == channel_type,
ChannelAccountORM.account_id == account_id,
ChannelAccountORM.is_deleted == 0,
UserORM.user_type == "service",

View File

@ -191,7 +191,7 @@ class ChannelCircuitBreaker:
# 由上层决定阻断或放行INV-7不回退 read-modify-write
await self._logger.warn(
"circuit_breaker half_open permits incr failed, fail-closed",
channel_type=channel_type.value,
channel_type=channel_type,
account_id=account_id,
error_type=type(exc).__name__,
error=str(exc),
@ -303,7 +303,7 @@ class ChannelCircuitBreaker:
# DependencyError由上层重试机制处理避免错误掩盖
await self._logger.warn(
"circuit_breaker incr failed, fail-closed",
channel_type=channel_type.value,
channel_type=channel_type,
account_id=account_id,
error_type=type(exc).__name__,
error=str(exc),
@ -366,7 +366,7 @@ class ChannelCircuitBreaker:
返回
缓存 key格式为 ``circuit_breaker:{channel_type}:{account_id}``
"""
return f"{self._CACHE_KEY_PREFIX}:{channel_type.value}:{account_id}"
return f"{self._CACHE_KEY_PREFIX}:{channel_type}:{account_id}"
def _failureCountKey(self, channel_type: ChannelType, account_id: str) -> str:
"""生成失败计数原子计数器缓存 key。
@ -381,7 +381,7 @@ class ChannelCircuitBreaker:
返回
缓存 key格式为 ``circuit_breaker:{channel_type}:{account_id}:failures``
"""
return f"{self._CACHE_KEY_PREFIX}:{channel_type.value}:{account_id}:failures"
return f"{self._CACHE_KEY_PREFIX}:{channel_type}:{account_id}:failures"
def _halfOpenPermitsKey(self, channel_type: ChannelType, account_id: str) -> str:
"""生成半开许可原子计数器缓存 key。
@ -398,7 +398,7 @@ class ChannelCircuitBreaker:
缓存 key格式为
``circuit_breaker:{channel_type}:{account_id}:half_open_permits``
"""
return f"{self._CACHE_KEY_PREFIX}:{channel_type.value}:{account_id}:half_open_permits"
return f"{self._CACHE_KEY_PREFIX}:{channel_type}:{account_id}:half_open_permits"
async def _getState(self, key: str) -> CircuitState | None:
"""从缓存读取熔断器状态。
@ -619,7 +619,7 @@ class ChannelCircuitBreaker:
await self._logger.warn(
"渠道熔断器已打开",
trace_id=trace_id,
channel_type=channel_type.value,
channel_type=channel_type,
account_id=account_id,
reason=reason,
)
@ -649,7 +649,7 @@ class ChannelCircuitBreaker:
await self._logger.info(
"渠道熔断器已关闭(恢复)",
trace_id=trace_id,
channel_type=channel_type.value,
channel_type=channel_type,
account_id=account_id,
)

View File

@ -256,6 +256,6 @@ class ChannelToolExecutor(ChannelToolExecutorPort):
匹配的 ``ToolsAdapter``未命中返回 ``None``
"""
for ct, adapter in self._registry.items():
if ct.value == channel_type:
if ct == channel_type:
return adapter
return None

View File

@ -337,7 +337,7 @@ class MessageOperationExecutor:
匹配的 ``MessageOpsAdapter``未命中返回 ``None``
"""
for ct, adapter in self._registry.items():
if ct.value == channel_type:
if ct == channel_type:
return adapter
return None

View File

@ -103,7 +103,7 @@ class PluginLifecycleAuditHandler:
target_channel: str | None = None
plugin_manifest = self._plugin_registry.getPlugin(plugin_id)
if plugin_manifest is not None:
target_channel = plugin_manifest.manifest.channel_type.value
target_channel = plugin_manifest.manifest.channel_type
# 构造参数摘要携带事件原始载荷字段version / reason / error
params_summary: dict[str, object] = dict(payload)

View File

@ -102,7 +102,7 @@ class ChannelProbe:
latency_ms = int((time.monotonic() - start) * 1000)
await self._logger.warn(
"渠道探测超时",
channel_type=channel_type.value,
channel_type=channel_type,
timeout=str(self.PROBE_TIMEOUT),
latency_ms=str(latency_ms),
)
@ -128,7 +128,7 @@ class ChannelProbe:
except Exception as exc:
await self._logger.warn(
"渠道探测下游检查DB ping 异常",
channel_type=channel_type.value,
channel_type=channel_type,
error=str(exc),
)
db_ok = False
@ -149,7 +149,7 @@ class ChannelProbe:
except Exception as exc:
await self._logger.warn(
"渠道探测下游检查Redis ping 异常",
channel_type=channel_type.value,
channel_type=channel_type,
error=str(exc),
)
redis_ok = False
@ -167,7 +167,7 @@ class ChannelProbe:
except Exception as exc:
await self._logger.warn(
"渠道探测下游检查Worker 状态查询异常",
channel_type=channel_type.value,
channel_type=channel_type,
error=str(exc),
)
worker_status = None
@ -202,7 +202,7 @@ class ChannelProbe:
latency_ms = int((time.monotonic() - start) * 1000)
await self._logger.warn(
"渠道探测异常",
channel_type=channel_type.value,
channel_type=channel_type,
error=str(e),
latency_ms=str(latency_ms),
)
@ -217,7 +217,7 @@ class ChannelProbe:
latency_ms = outcome.latency_ms if outcome.latency_ms > 0 else int((time.monotonic() - start) * 1000)
await self._logger.info(
"渠道探测完成",
channel_type=channel_type.value,
channel_type=channel_type,
healthy=str(outcome.is_available),
latency_ms=str(latency_ms),
)

View File

@ -217,7 +217,7 @@ class DiagnosticsExporter:
"diagnostics_bundle": snapshot.diagnostics_bundle,
"channels": [
{
"channel_type": ch.channel_type.value,
"channel_type": ch.channel_type,
"account_id": ch.account_id,
"plugin_state": ch.plugin_state,
"adapter_states": ch.adapter_states,
@ -245,7 +245,7 @@ class DiagnosticsExporter:
``{channel_type:account_id: plugin_state}`` 映射以渠道类型与
账户 ID 组合为键保证唯一性
"""
return {f"{ch.channel_type.value}:{ch.account_id}": ch.plugin_state for ch in snapshot.channels}
return {f"{ch.channel_type}:{ch.account_id}": ch.plugin_state for ch in snapshot.channels}
def _collectRecentErrors(
self,
@ -280,7 +280,7 @@ class DiagnosticsExporter:
if ch.last_error:
errors.append(
{
"channel_type": ch.channel_type.value,
"channel_type": ch.channel_type,
"account_id": ch.account_id,
"plugin_state": ch.plugin_state,
"error": ch.last_error,

View File

@ -299,9 +299,7 @@ class HealthAggregator:
continue
for account_info in worker.accounts:
if account_info.state == "error":
degraded_components.append(
f"transport:{account_info.channel_type}:{account_info.account_id}"
)
degraded_components.append(f"transport:{account_info.channel_type}:{account_info.account_id}")
has_degraded = True
# 渠道账户映射channel_type → account_id从渠道配置读取供填充 ChannelHealth
@ -355,14 +353,14 @@ class HealthAggregator:
if self._logger is not None:
await self._logger.warn(
"circuit_breaker getState failed, degrade to None",
channel_type=channel_type.value,
channel_type=channel_type,
account_id=account_id,
error=str(exc),
)
cb_state = None
cb_state_str = cb_state.status if cb_state is not None else None
if cb_state is not None and cb_state.status == "open":
degraded_components.append(f"circuit_breaker:{channel_type.value}")
degraded_components.append(f"circuit_breaker:{channel_type}")
has_degraded = True
# 适配器状态:清单声明的适配器映射为插件状态;无声明时回退至 plugin 键
@ -536,7 +534,7 @@ class HealthAggregator:
def _channelHealthToDict(ch: ChannelHealth) -> dict[str, Any]:
"""将 ``ChannelHealth`` 序列化为 JSON-safe dict。"""
return {
"channel_type": ch.channel_type.value,
"channel_type": ch.channel_type,
"account_id": ch.account_id,
"plugin_state": ch.plugin_state,
"adapter_states": dict(ch.adapter_states),
@ -727,7 +725,9 @@ class HealthAggregator:
diagnostics_bundle=data.get("diagnostics_bundle"),
worker_status=HealthAggregator._dictToWorkerStatus(data.get("worker_status")),
redis_stream_status=HealthAggregator._dictToRedisStreamStatus(data.get("redis_stream_status")),
db_connection_pool_status=HealthAggregator._dictToConnectionPoolStatus(data.get("db_connection_pool_status")),
db_connection_pool_status=HealthAggregator._dictToConnectionPoolStatus(
data.get("db_connection_pool_status")
),
transport_status=HealthAggregator._dictToTransportHealth(data.get("transport_status")),
error_code=data.get("error_code", ""),
trace_id=data.get("trace_id", ""),

View File

@ -1121,11 +1121,13 @@ class PluginLifecycleManager:
``UpdateConfigCmd`` 时传入 ``expected_version`` 实现乐观并发控制
避免并发加载场景下的 read-then-write 竞态
键不存在与校验错误的区分``ConfigPort.get`` "键不存在""值校验
失败"时均抛 ``ConfigValidationError``,契约未提供直接区分手段。本方法
利用 ``ConfigPort.getVersion`` 返回值间接判断版本 0 表示键从未
写入首次加载合法降级返回空列表版本 > 0 表示键存在但值校验
失败配置损坏fail-fast ``InternalError``避免静默吞掉掩盖问题
键不存在与校验错误的区分``applied_migrations`` 已在
``CONFIG_SCHEMA`` 声明 ``default=[]``首次加载时 ``ConfigPort.get``
返回空列表版本 0不再抛 ``NotFoundError````try-except`` 仅用于
兜底 Redis 存值损坏JSON 解析失败触发的 ``ConfigValidationError``
通过 ``ConfigPort.getVersion`` 间接判断版本 0 视为首次加载降级为
空列表版本 > 0 视为键存在但值损坏fail-fast ``InternalError``
避免静默吞掉掩盖问题
写入失败抛异常向上传播触发插件加载失败处理``_handle_failure``
``applied_migrations`` 影响 ``DoctorService`` 迁移检查非观测层配置
@ -1140,11 +1142,12 @@ class PluginLifecycleManager:
InternalError: ``applied_migrations`` 键存在版本 > 0但值校验
失败表明配置损坏需 fail-fast
"""
target = manifest.channel_type.value
# 读取已有迁移列表(首次加载时配置不存在,返回空列表)
# ConfigPort.get 在"键不存在"与"校验失败"时均抛 ConfigValidationError
# 契约未提供直接区分手段;通过 getVersion 返回值间接判断:
# 版本 0 → 键不存在(合法降级),版本 > 0 → 键存在但校验失败fail-fast
target = manifest.channel_type
# 读取已有迁移列表。applied_migrations 已在 CONFIG_SCHEMA 声明
# default=[],首次加载时 ConfigPort.get 直接返回空列表(版本 0
# try-except 仅兜底 Redis 存值损坏JSON 解析失败)触发的
# ConfigValidationError版本 0 视为首次加载降级,版本 > 0 视为
# 键存在但值损坏fail-fast
existing: list[str] = []
try:
value = await config_port.get(

View File

@ -405,13 +405,13 @@ class PluginLoader:
message=f"清单缺少必填字段: {field_name}",
)
try:
channel_type = ChannelType(data["channel_type"])
except ValueError as e:
channel_type_raw = data.get("channel_type")
if not isinstance(channel_type_raw, str) or not channel_type_raw:
raise ValidationError(
field="channel_type",
message=f"未知的渠道类型: {data['channel_type']}",
) from e
message="channel_type must be a non-empty string",
)
channel_type = ChannelType(channel_type_raw)
try:
failure_policy = FailurePolicy(data["failure_policy"])

View File

@ -183,13 +183,13 @@ class OutboxRetryWorker:
if self._isPluginFailed(channel_type):
await self._publishChannelDegraded(context, channel_type, "插件处于 FAILED 状态")
raise ChannelDegradedError(
channel=channel_type.value,
channel=channel_type,
trace_id=trace_id,
)
if await self._circuit_breaker.isOpen(channel_type, context.account_id):
raise ChannelDegradedError(
channel=channel_type.value,
channel=channel_type,
trace_id=trace_id,
)
@ -200,7 +200,7 @@ class OutboxRetryWorker:
await self._logger.warn(
"outbound adapter 未注册,跳过重试",
outbox_id=outbox_id,
channel_type=channel_type.value,
channel_type=channel_type,
)
return
@ -290,7 +290,7 @@ class OutboxRetryWorker:
await self._logger.info(
"outbox 重试成功",
outbox_id=entry.outbox_id,
channel_type=channel_type.value,
channel_type=channel_type,
account_id=context.account_id,
channel_msg_id=channel_msg_id,
)
@ -334,7 +334,7 @@ class OutboxRetryWorker:
await self._logger.warn(
"outbox 重试失败",
outbox_id=entry.outbox_id,
channel_type=channel_type.value,
channel_type=channel_type,
account_id=context.account_id,
retry_count=aggregate.retry_count,
status=aggregate.status.value,
@ -384,7 +384,7 @@ class OutboxRetryWorker:
await self._event_publisher.publish(event.toDomainEvent())
await self._logger.warn(
"出站投递暂停:渠道插件降级",
channel_type=channel_type.value,
channel_type=channel_type,
account_id=context.account_id,
reason=reason,
)

View File

@ -230,7 +230,7 @@ class Pipeline:
channel_format_spec = getattr(ctx, "channel_format_spec", None)
channel_context_note = getattr(ctx, "channel_context_note", None)
if channel_type is not None:
fields["channel_type"] = channel_type.value
fields["channel_type"] = channel_type
if account_id is not None:
fields["account_id"] = account_id
if agent_run_id is not None:
@ -247,7 +247,7 @@ class Pipeline:
fields["channel_context_note"] = channel_context_note
# 派生 channel_id等价于 ${channel_type}:${account_id}
if channel_type is not None and account_id is not None:
fields["channel_id"] = f"{channel_type.value}:{account_id}"
fields["channel_id"] = f"{channel_type}:{account_id}"
if not ok:
fields["error_type"] = type(err).__name__ if err else None
try:

View File

@ -87,7 +87,7 @@ class AuditContextBuilder:
group_id / keyword便于审计排查定位具体查询目标
login/* 操作target 包含渠道类型与账户 ID便于审计排查定位
具体账户的登录操作
其他操作优先取 ``params.target``其次 ``target_channel.value``
其他操作优先取 ``params.target``其次 ``target_channel``
兜底为 operation 字符串
参数
@ -102,7 +102,7 @@ class AuditContextBuilder:
return self._computeDirectoryTarget(ctx)
if ctx.operation.startswith("login/"):
return self._computeLoginTarget(ctx)
return ctx.params.get("target") or (ctx.target_channel.value if ctx.target_channel else ctx.operation)
return ctx.params.get("target") or (ctx.target_channel if ctx.target_channel else ctx.operation)
def _computeLoginTarget(self, ctx: ControlPlaneContext) -> str:
"""计算 login/* 操作的审计目标字符串。
@ -116,7 +116,7 @@ class AuditContextBuilder:
返回
审计目标字符串包含渠道与账户信息
"""
channel = ctx.target_channel.value if ctx.target_channel else "unknown"
channel = ctx.target_channel if ctx.target_channel else "unknown"
account_id = ctx.params.get("account_id", "")
return f"{channel}:{account_id}"
@ -133,7 +133,7 @@ class AuditContextBuilder:
返回
审计目标字符串包含渠道账户与查询对象信息
"""
channel = ctx.target_channel.value if ctx.target_channel else "unknown"
channel = ctx.target_channel if ctx.target_channel else "unknown"
account_id = ctx.params.get("account_id", "")
if ctx.operation == "directory/users":
keyword = ctx.params.get("keyword", "")
@ -228,7 +228,7 @@ class AuditContextBuilder:
request_id=ctx.request_id,
message_id=message_id,
content_summary=content_summary,
target_channel=ctx.target_channel.value if ctx.target_channel else None,
target_channel=ctx.target_channel if ctx.target_channel else None,
target_account=ctx.params.get("target_account"),
)
@ -323,7 +323,7 @@ class AuditContextBuilder:
trace_id=ctx.trace_id,
source_ip=ctx.operator.ip,
request_id=ctx.request_id,
target_channel=ctx.target_channel.value if ctx.target_channel else None,
target_channel=ctx.target_channel if ctx.target_channel else None,
target_account=account_id,
)
try:

View File

@ -34,7 +34,6 @@ from yuxi.channels.application.pipeline.control_plane.handlers.analytics_handler
from yuxi.channels.application.pipeline.control_plane.handlers.audit_handler import (
AuditHandler,
)
from yuxi.channels.application.pipeline.control_plane.handlers.base import BatchExecutor
from yuxi.channels.application.pipeline.control_plane.handlers.capability_handler import (
CapabilityHandler,
)
@ -347,10 +346,9 @@ class ControlPlanePipeline(Pipeline):
logger=logger,
masking_port=masking_port,
)
# 共享 Pattern D 批量编排器PluginHandler 接收已构造的 BatchExecutor
# 其余批量 handlerconfig/account/message/session/content_review/
# pairing/whitelist/outbox内部按 transaction_port 自建。
batch_executor = BatchExecutor(transaction_port)
# 所有批量 handlerconfig/account/message/session/content_review/
# pairing/whitelist/outbox/plugin均按 transaction_port 内部自建
# BatchExecutor无共享实例。
handlers = [
ConfigHandler(
config_manager=config_manager,
@ -457,7 +455,11 @@ class ControlPlanePipeline(Pipeline):
transaction_port=transaction_port,
logger=logger,
),
DoctorHandler(doctor_service=doctor_service, logger=logger),
DoctorHandler(
doctor_service=doctor_service,
persistence_port=persistence_port,
logger=logger,
),
WebhookHandler(
persistence_port=persistence_port,
webhook_test_adapters=webhook_test_adapters,
@ -468,7 +470,7 @@ class ControlPlanePipeline(Pipeline):
plugin_loader=plugin_loader,
plugin_registry=plugin_registry,
config_manager=config_manager,
batch_executor=batch_executor,
transaction_port=transaction_port,
logger=logger,
),
OutboxHandler(

View File

@ -237,6 +237,16 @@ class AccountHandler(ControlPlaneHandler):
raw_config = _requireParam(ctx, "raw_config")
display_name = _requireParam(ctx, "display_name")
# 前置校验channel_type 必须已注册生命周期适配器。未注册时
# lifecycle service 的 _requireAdapter 会抛 NotImplementedError501
# 此处提前翻译为 RuleViolationError422明确告知客户端该渠道
# 类型不支持账户创建。
if not self._lifecycle_service.hasLifecycleCapability(channel_type):
raise RuleViolationError(
rule=f"unsupported_channel_type:{channel_type}",
trace_id=ctx.trace_id,
)
# 1. 解析账户 ID失败抛 ValidationError中止
account_id = await self._lifecycle_service.resolveAccountId(channel_type, raw_config, ctx.trace_id)
@ -735,7 +745,7 @@ class AccountHandler(ControlPlaneHandler):
return {
"account_id": account_id,
"channel_type": channel_type.value,
"channel_type": channel_type,
"status": account_dto.status.value,
"enabled": account_dto.enabled,
"circuit_breaker": _serializeCircuitState(circuit_state),
@ -767,11 +777,11 @@ class AccountHandler(ControlPlaneHandler):
plugin = self.plugin_registry.getPluginByChannelType(channel_type)
if plugin is None:
raise NotFoundError("plugin", channel_type.value, trace_id=ctx.trace_id)
raise NotFoundError("plugin", channel_type, trace_id=ctx.trace_id)
lifecycle_adapter = plugin.lifecycle_adapter
if lifecycle_adapter is None or not hasattr(lifecycle_adapter, "rotateCredentials"):
raise NotImplementedError(
f"rotateCredentials not supported for {channel_type.value}",
f"rotateCredentials not supported for {channel_type}",
trace_id=ctx.trace_id,
)
@ -824,7 +834,7 @@ class AccountHandler(ControlPlaneHandler):
plugin = self.plugin_registry.getPluginByChannelType(channel_type)
if plugin is None:
raise NotFoundError("plugin", channel_type.value, trace_id=ctx.trace_id)
raise NotFoundError("plugin", channel_type, trace_id=ctx.trace_id)
start_time = time.monotonic()
try:
@ -899,7 +909,7 @@ class AccountHandler(ControlPlaneHandler):
if filter_cond and "channel_type" in filter_cond:
channel_type_value = filter_cond["channel_type"]
elif ctx.target_channel is not None:
channel_type_value = ctx.target_channel.value
channel_type_value = ctx.target_channel
if channel_type_value is None:
raise ValidationError(
"channel_type",
@ -995,7 +1005,7 @@ class AccountHandler(ControlPlaneHandler):
exported_at = utc_now()
return {
"account_id": account_id,
"channel_type": channel_type.value,
"channel_type": channel_type,
"display_name": account_dto.display_name,
"raw_config": raw_config,
"exported_at": exported_at.isoformat(),
@ -1046,7 +1056,7 @@ class AccountHandler(ControlPlaneHandler):
)
plugin = self.plugin_registry.getPluginByChannelType(channel_type)
if plugin is None:
raise NotFoundError("plugin", channel_type.value, trace_id=ctx.trace_id)
raise NotFoundError("plugin", channel_type, trace_id=ctx.trace_id)
if not plugin.manifest.supports_credential_cloning:
raise RuleViolationError(
rule="credential_cloning_not_supported",

View File

@ -22,10 +22,10 @@ from yuxi.channels.application.pipeline.control_plane.handlers.base import (
)
from yuxi.channels.contract.errors import ValidationError
from yuxi.channels.contract.errors.server import NotImplementedError
from yuxi.channels.contract.ports.driven.logger_port import LoggerPort
from yuxi.channels.contract.ports.driven.agent_management_port import (
AgentManagementPort,
)
from yuxi.channels.contract.ports.driven.logger_port import LoggerPort
class AgentHandler(ControlPlaneHandler):

View File

@ -59,7 +59,7 @@ class CapabilityHandler(ControlPlaneHandler):
plugins = self.plugin_registry.listPlugins()
capabilities = [
{
"channel_type": plugin.manifest.channel_type.value,
"channel_type": plugin.manifest.channel_type,
"capabilities": plugin.manifest.capabilities,
}
for plugin in plugins
@ -83,11 +83,11 @@ class CapabilityHandler(ControlPlaneHandler):
if plugin is None:
raise NotFoundError(
resource="plugin",
id=channel_type.value,
id=channel_type,
trace_id=ctx.trace_id,
)
return {
"channel_type": channel_type.value,
"channel_type": channel_type,
"capabilities": plugin.manifest.capabilities,
}

View File

@ -47,9 +47,9 @@ from yuxi.channels.contract.dtos.config import (
UpdateConfigCmd,
)
from yuxi.channels.contract.errors import Error, NotFoundError, ValidationError
from yuxi.channels.contract.policy.config_schema import HOT_RELOADABLE_KEYS
from yuxi.channels.contract.ports.driven.logger_port import LoggerPort
from yuxi.channels.contract.ports.driven.transaction_port import TransactionPort
from yuxi.channels.contract.policy.config_schema import HOT_RELOADABLE_KEYS
from yuxi.channels.core.service.config_manager import ConfigManager
from yuxi.utils.datetime_utils import utc_now
@ -199,6 +199,22 @@ class ConfigHandler(ControlPlaneHandler):
scope,
target,
)
# 历史为空时区分"key 不存在"与"key 存在但无历史"
# key 不存在config_port.get 抛 NotFoundError时返回 404
# key 存在但无历史记录时返回空列表 200。
if not entries:
try:
await self.config_manager.config_port.get(
params["key"],
scope,
target,
)
except NotFoundError:
raise NotFoundError(
"config",
params["key"],
trace_id=ctx.trace_id,
)
return {
"key": params["key"],
"history": [

View File

@ -234,7 +234,8 @@ class DashboardHandler:
by_channel: tuple[ChannelRealtimeStat, ...] = ()
if self._realtime_metrics is not None:
messages_per_second = await self._realtime_metrics.getMPS(
query.window_seconds, query.channel_type,
query.window_seconds,
query.channel_type,
)
active_sessions = await self._realtime_metrics.getActiveSessionCount(
query.channel_type,
@ -246,23 +247,21 @@ class DashboardHandler:
# 单渠道过滤:复用顶层已查询的指标,避免重复请求
by_channel = (
ChannelRealtimeStat(
channel_type=query.channel_type.value,
channel_type=query.channel_type,
mps=messages_per_second,
active_sessions=active_sessions,
),
)
elif self._plugin_registry is not None:
# 全局查询:遍历已注册渠道逐渠道查询,确保 by_channel 不为空
registered_types = {
ct for ct, _ in self._plugin_registry.listPluginAdapters()
}
registered_types = {ct for ct, _ in self._plugin_registry.listPluginAdapters()}
by_channel = tuple(
ChannelRealtimeStat(
channel_type=ct.value,
channel_type=ct,
mps=await self._realtime_metrics.getMPS(query.window_seconds, ct),
active_sessions=await self._realtime_metrics.getActiveSessionCount(ct),
)
for ct in sorted(registered_types, key=lambda c: c.value)
for ct in sorted(registered_types, key=lambda c: c)
)
result = RealtimeMetricsResult(

View File

@ -96,7 +96,7 @@ class DirectoryHandler(ControlPlaneHandler):
adapter = self.directory_adapter_registry.get(ctx.target_channel)
if adapter is None:
raise NotImplementedError(
f"directory/{ctx.target_channel.value}",
f"directory/{ctx.target_channel}",
trace_id=ctx.trace_id,
)
return adapter
@ -116,7 +116,7 @@ class DirectoryHandler(ControlPlaneHandler):
)
if not bool(value.value):
raise NotImplementedError(
f"directory@{ctx.target_channel.value if ctx.target_channel else 'unknown'}",
f"directory@{ctx.target_channel if ctx.target_channel else 'unknown'}",
trace_id=ctx.trace_id,
)
@ -175,7 +175,7 @@ class DirectoryHandler(ControlPlaneHandler):
limit=int(params.get("limit", _DEFAULT_LIST_LIMIT)),
)
cache_key = (
f"directory:users:{ctx.target_channel.value if ctx.target_channel else ''}"
f"directory:users:{ctx.target_channel if ctx.target_channel else ''}"
f":{account_id}:search:{query.keyword or ''}:{query.cursor or ''}:{query.limit}"
)
@ -209,7 +209,7 @@ class DirectoryHandler(ControlPlaneHandler):
limit=int(params.get("limit", _DEFAULT_LIST_LIMIT)),
)
cache_key = (
f"directory:groups:{ctx.target_channel.value if ctx.target_channel else ''}"
f"directory:groups:{ctx.target_channel if ctx.target_channel else ''}"
f":{account_id}:search:{query.keyword or ''}:{query.cursor or ''}:{query.limit}"
)
@ -243,7 +243,7 @@ class DirectoryHandler(ControlPlaneHandler):
"user_id is required for directory/user_profile",
trace_id=ctx.trace_id,
)
cache_key = f"directory:users:{ctx.target_channel.value if ctx.target_channel else ''}:{account_id}:profile:{user_id}"
cache_key = f"directory:users:{ctx.target_channel if ctx.target_channel else ''}:{account_id}:profile:{user_id}"
async def _fetch() -> dict[str, Any]:
user = await adapter.getUserProfile(user_id)
@ -282,7 +282,7 @@ class DirectoryHandler(ControlPlaneHandler):
limit=int(params.get("limit", _DEFAULT_LIST_LIMIT)),
)
cache_key = (
f"directory:groups:{ctx.target_channel.value if ctx.target_channel else ''}"
f"directory:groups:{ctx.target_channel if ctx.target_channel else ''}"
f":{account_id}:members:{group_id}:{query.cursor or ''}:{query.limit}"
)
@ -316,7 +316,9 @@ class DirectoryHandler(ControlPlaneHandler):
"group_id is required for directory/group_detail",
trace_id=ctx.trace_id,
)
cache_key = f"directory:groups:{ctx.target_channel.value if ctx.target_channel else ''}:{account_id}:detail:{group_id}"
cache_key = (
f"directory:groups:{ctx.target_channel if ctx.target_channel else ''}:{account_id}:detail:{group_id}"
)
async def _fetch() -> dict[str, Any]:
group = await adapter.getGroupDetail(group_id)
@ -360,7 +362,7 @@ class DirectoryHandler(ControlPlaneHandler):
# 失效 CachePort 中对应的本地 TTL 缓存。
# pattern 的 scope 段与缓存 key 第二段对齐,单一 pattern 覆盖
# 该 scope 下所有子类型search / profile / detail / members
channel_value = ctx.target_channel.value if ctx.target_channel else ""
channel_value = ctx.target_channel if ctx.target_channel else ""
if self.cache_port is not None:
if scope == "all":
pattern = f"directory:*:{channel_value}:{account_id}:*"

View File

@ -28,9 +28,10 @@ from yuxi.channels.application.pipeline.control_plane.serializers import (
_requireAccountId,
)
from yuxi.channels.contract.dtos.channel import ChannelType
from yuxi.channels.contract.errors import ValidationError
from yuxi.channels.contract.errors import NotFoundError, ValidationError
from yuxi.channels.contract.errors.server import NotImplementedError
from yuxi.channels.contract.ports.driven.logger_port import LoggerPort
from yuxi.channels.contract.ports.driven.persistence_port import PersistencePort
from yuxi.channels.core.service.doctor_service import DoctorService
#: check_id 合法格式(字母 / 数字 / 下划线 / 连字符,非空)。
@ -77,8 +78,14 @@ class DoctorHandler:
``/repair`` 裸路径顺序匹配避免 ``{check_id}`` 误捕获带后缀的路径
"""
def __init__(self, doctor_service: DoctorService | None = None, logger: LoggerPort | None = None) -> None:
def __init__(
self,
doctor_service: DoctorService | None = None,
persistence_port: PersistencePort | None = None,
logger: LoggerPort | None = None,
) -> None:
self.doctor_service = doctor_service
self.persistence_port = persistence_port
self._logger = logger
def operations(
@ -119,6 +126,10 @@ class DoctorHandler:
"""
channel_type = self._requireDoctorTargetChannel(ctx)
account_id = _requireAccountId(ctx)
# 校验账户存在性(契约要求:账户不存在抛 NotFoundError
account = await self.persistence_port.getChannelAccount(channel_type, account_id)
if account is None:
raise NotFoundError(resource="channel_account", id=account_id, trace_id=ctx.trace_id)
return await self.doctor_service.getChecks(channel_type, account_id, trace_id=ctx.trace_id)
async def _doctorRun(self, ctx: ControlPlaneContext) -> dict[str, Any]:

View File

@ -144,7 +144,7 @@ class HealthHandler:
status = "disabled"
return {
"channel_type": channel_type.value,
"channel_type": channel_type,
"account_id": account_id,
"status": status,
"plugin_state": plugin_state,
@ -202,7 +202,7 @@ class HealthHandler:
result_str = "reachable" if result.healthy else "unreachable"
return {
"channel_type": channel_type.value,
"channel_type": channel_type,
"account_id": account_id,
"probe_type": probe_type,
"result": result_str,

View File

@ -97,6 +97,10 @@ class LoginHandler(ControlPlaneHandler):
raise NotImplementedError(ctx.operation, trace_id=ctx.trace_id)
channel_type = ctx.target_channel
account_id = _requireAccountId(ctx)
# 校验账户存在性(契约要求:账户不存在抛 NotFoundError
account = await self.persistence_port.getChannelAccount(channel_type, account_id)
if account is None:
raise NotFoundError(resource="channel_account", id=account_id, trace_id=ctx.trace_id)
status = await self._login_service.getLoginStatus(channel_type, account_id)
return {"login_status": status.value}

View File

@ -211,7 +211,7 @@ class MessageHandler(ControlPlaneHandler):
``role='admin'`` 跨会话过滤管理员发送历史支持按渠道类型与时间
范围进一步过滤 ``created_at`` 降序返回``limit`` 默认 50上限
200支持 ``offset`` 分页返回 ``{"messages": [...], "total": int}``
200支持 ``offset`` 分页返回 ``{"items": [...], "total": int}``
供运营审计场景使用
参数``ctx.params``
@ -260,7 +260,7 @@ class MessageHandler(ControlPlaneHandler):
end_time=end_time,
channel_status=channel_status,
)
return {"messages": [_messageToDict(m, self._masking) for m in messages], "total": total}
return {"items": [_messageToDict(m, self._masking) for m in messages], "total": total}
async def _messageGet(self, ctx: ControlPlaneContext) -> dict[str, Any]:
"""执行 message/get 操作MSG-01 消息详情用例)。
@ -315,7 +315,7 @@ class MessageHandler(ControlPlaneHandler):
return {
"message_id": message.message_id,
"conversation_id": message.conversation_id,
"channel_type": message.channel_type.value if message.channel_type else None,
"channel_type": message.channel_type if message.channel_type else None,
"channel_msg_id": message.channel_msg_id,
"channel_status": message.channel_status,
"channel_status_history": message.channel_status_history,
@ -370,8 +370,7 @@ class MessageHandler(ControlPlaneHandler):
):
raise ValidationError(
"target_channel",
f"target_channel {ctx.target_channel.value} does not match "
f"message.channel_type {message.channel_type.value}",
f"target_channel {ctx.target_channel} does not match message.channel_type {message.channel_type}",
trace_id=ctx.trace_id,
)
channel_type = ctx.target_channel or message.channel_type
@ -504,8 +503,7 @@ class MessageHandler(ControlPlaneHandler):
):
raise ValidationError(
"target_channel",
f"target_channel {ctx.target_channel.value} does not match "
f"message.channel_type {message.channel_type.value}",
f"target_channel {ctx.target_channel} does not match message.channel_type {message.channel_type}",
trace_id=ctx.trace_id,
)

View File

@ -446,9 +446,7 @@ class OutboxHandler(ControlPlaneHandler):
async def _enqueue_revived() -> None:
for outbox_id in revived_ids_snapshot:
await queue_port.enqueue(
EnqueueCmd(task_name="outbox_retry", payload={"outbox_id": outbox_id})
)
await queue_port.enqueue(EnqueueCmd(task_name="outbox_retry", payload={"outbox_id": outbox_id}))
ctx.post_commit_hooks.append(_enqueue_revived)

View File

@ -66,8 +66,8 @@ from yuxi.channels.contract.errors import (
)
from yuxi.channels.contract.errors.server import (
NotImplementedError,
OperationTimeoutError,
)
from yuxi.channels.contract.errors.server import OperationTimeoutError
from yuxi.channels.contract.plugin.lifecycle import LifecycleState
from yuxi.channels.contract.ports.driven.logger_port import LoggerPort
from yuxi.channels.contract.ports.driven.plugin_lifecycle_manager_port import (
@ -225,7 +225,7 @@ class PluginHandler(ControlPlaneHandler):
plugin_id=p.manifest.id,
name=p.manifest.name,
version=p.manifest.version,
channel_type=p.manifest.channel_type.value,
channel_type=p.manifest.channel_type,
state=state.value if state else LifecycleState.UNLOADED.value,
)
)
@ -248,7 +248,7 @@ class PluginHandler(ControlPlaneHandler):
plugin_id=p.manifest.id,
name=p.manifest.name,
version=p.manifest.version,
channel_type=p.manifest.channel_type.value,
channel_type=p.manifest.channel_type,
state=state.value if state else LifecycleState.UNLOADED.value,
capabilities=p.manifest.capabilities,
)
@ -276,7 +276,7 @@ class PluginHandler(ControlPlaneHandler):
plugin_id=manifest.manifest.id,
name=manifest.manifest.name,
version=manifest.manifest.version,
channel_type=manifest.manifest.channel_type.value,
channel_type=manifest.manifest.channel_type,
state=state.value if state else LifecycleState.UNLOADED.value,
manifest=manifest,
)
@ -609,7 +609,7 @@ class PluginHandler(ControlPlaneHandler):
) from e
plugins = self.plugin_registry.listPlugins()
for pm in plugins:
if channel_type_value is not None and pm.manifest.channel_type.value != channel_type_value:
if channel_type_value is not None and pm.manifest.channel_type != channel_type_value:
continue
if target_state is not None:
s = self.plugin_registry.getPluginState(pm.manifest.id)

View File

@ -16,7 +16,7 @@ from __future__ import annotations
import dataclasses
from collections.abc import Awaitable, Callable
from datetime import UTC, datetime
from datetime import UTC, datetime, timedelta
from typing import Any
from uuid import uuid4
@ -51,6 +51,7 @@ from yuxi.channels.contract.ports.driven.report_repository_port import (
ReportRepositoryPort,
)
from yuxi.channels.core.model.report import ReportAggregate
from yuxi.scheduler.exceptions import SchedulerValidationError
from yuxi.scheduler.use_cases.dto.scheduler import (
CreateTaskInput,
DeleteTaskInput,
@ -104,9 +105,10 @@ class ReportsHandler(ControlPlaneHandler):
run_at 处理UseCase 已将 datetime 序列化为 ISO 8601 字符串放入
``ctx.params``审计安全Handler 通过 ``_coerceQueryDateTime``
还原为 aware UTC datetime``run_at`` None 时默认当前 aware
UTC 时间立即执行统一 ``.isoformat()`` 为带 ``+00:00`` 后缀
ISO 字符串传给调度器
还原为 aware UTC datetime再统一转换为 naive UTC 传给调度器
与调度器内部 ``utc_now_naive()`` 比较保持时区一致
``run_at`` None 时默认 naive UTC + 1s 缓冲立即执行
确保 ``run_at`` 始终为未来时间以通过调度器校验
"""
if self._report_repository is None or self._scheduler_service is None:
raise NotImplementedError(ctx.operation, trace_id=ctx.trace_id)
@ -123,9 +125,16 @@ class ReportsHandler(ControlPlaneHandler):
)
if run_at_raw is None:
run_at_dt = datetime.now(UTC)
# 默认立即执行:使用 naive UTC + 1s 缓冲,确保通过调度器
# "run_at 必须为未来时间" 校验handler 与 scheduler 间存在
# 处理时延,直接使用 now 会被 <= utc_now_naive() 判定为非未来)。
run_at_dt = utc_now_naive() + timedelta(seconds=1)
else:
# 用户提供的 run_at 经 _coerceQueryDateTime 还原为 aware UTC
# 统一转换为 naive UTC 后传给调度器,与调度器内部
# utc_now_naive() 比较保持时区一致Task 6 时区修复同款)。
run_at_dt = _coerceQueryDateTime(run_at_raw, "run_at", ctx.trace_id)
run_at_dt = run_at_dt.astimezone(UTC).replace(tzinfo=None)
report_id = f"rpt_{uuid4().hex}"
@ -147,7 +156,16 @@ class ReportsHandler(ControlPlaneHandler):
delete_after_run=True,
created_by=ctx.operator.user_id,
)
task_output = await self._scheduler_service.create_task(task_input)
try:
task_output = await self._scheduler_service.create_task(task_input)
except SchedulerValidationError as e:
# 调度器业务规则校验失败(如 run_at 非未来时间)转为 ValidationError
# 400避免 SchedulerValidationError 作为非契约层异常透传为 500。
raise ValidationError(
field="run_at",
message=str(e),
trace_id=ctx.trace_id,
) from e
report_with_task = Report(
report_id=report.report_id,

View File

@ -123,7 +123,20 @@ class SessionHandler(ControlPlaneHandler):
策略门读取 ``merge_strategy_enabled`` 配置falsy 时抛
``RuleViolationError``
检查顺序先校验目标会话存在404再检查策略门422
确保不存在的会话返回 404 而非 422
"""
# 必填参数经 _requireParam 校验,缺失时抛 ValidationError400
target_conversation_id = _requireParam(ctx, "target_conversation_id")
# 目标会话存在性检查:路径 session_id 映射为 target_conversation_id
# 通过 conversation_id 反查渠道会话,不存在时抛 NotFoundError404
# 此检查在策略门之前,确保非存在会话返回 404 而非 422。
target_session = await self.persistence_port.getChannelSessionByConversationId(
target_conversation_id,
)
if target_session is None:
raise NotFoundError("channel_session", target_conversation_id, trace_id=ctx.trace_id)
# FR-07 合并策略开关:默认禁用。策略门在 usecase 层执行,
# adapter 不再承载此业务规则INV-8
strategy_value = await self.config_manager.config_port.get("merge_strategy_enabled")
@ -132,10 +145,9 @@ class SessionHandler(ControlPlaneHandler):
"merge_strategy_disabled",
trace_id=ctx.trace_id,
)
# 必填参数经 _requireParam 校验,缺失时抛 ValidationError400
cmd = MergeConversationCmd(
source_conversation_id=_requireParam(ctx, "source_conversation_id"),
target_conversation_id=_requireParam(ctx, "target_conversation_id"),
target_conversation_id=target_conversation_id,
operator=ctx.operator,
reason=ctx.params.get("reason", ""),
)
@ -236,7 +248,7 @@ class SessionHandler(ControlPlaneHandler):
abnormal=abnormal,
)
return {
"sessions": [_channelSessionToDict(s) for s in sessions],
"items": [_channelSessionToDict(s) for s in sessions],
"total": total,
}

View File

@ -86,7 +86,7 @@ class WebhookHandler(ControlPlaneHandler):
if not accounts:
raise NotFoundError(
resource="channel_account",
id=f"{channel_type.value}:<first>",
id=f"{channel_type}:<first>",
trace_id=ctx.trace_id,
)
account_dto = accounts[0]

View File

@ -167,10 +167,7 @@ class WhitelistHandler(ControlPlaneHandler):
if keyword:
kw = keyword.lower()
entries = tuple(
e
for e in entries
if kw in e.peer_id.lower()
or (e.peer_name is not None and kw in e.peer_name.lower())
e for e in entries if kw in e.peer_id.lower() or (e.peer_name is not None and kw in e.peer_name.lower())
)
total = len(entries)

View File

@ -378,9 +378,7 @@ _OPERATION_AUDIT_META: dict[str, OperationAuditMeta] = {
# outbox/retry 仅读 getOutboxEntry + 外部入队 enqueue无 DB 写入。
# db_tx_required=False 跳过 DB 事务包裹,避免无谓占用连接池;
# 审计使用 INDEPENDENT 独立事务 best-effort§10.1 事务边界)。
"outbox/retry": OperationAuditMeta(
audit_type="outbox_retry", tx_strategy="INDEPENDENT", db_tx_required=False
),
"outbox/retry": OperationAuditMeta(audit_type="outbox_retry", tx_strategy="INDEPENDENT", db_tx_required=False),
"outbox/dead_letter_delete": OperationAuditMeta(audit_type="outbox_dead_letter_deleted", tx_strategy="COOPERATIVE"),
# ---- capability ----CAP-01~CAP-02
# capability 查询为只读,审计类型 admin_query审计日志与主操作共享事务fail-closed

View File

@ -150,24 +150,22 @@ def _coerceQueryDateTime(
def _coerceChannelType(value: Any, trace_id: str | None) -> ChannelType | None:
"""将查询参数中的渠道类型值统一转换为 ``ChannelType`` 枚举
"""将查询参数中的渠道类型值统一转换为 ``ChannelType``
支持 ``ChannelType`` 实例与合法枚举值字符串``None`` 原样返回
非法值抛 ``ValidationError``400避免原生异常被 ``_executeControl``
兜底翻译为 ``InternalError``500
支持非空字符串与 ``ChannelType`` 实例``None`` 原样返回
非法值空字符串非字符串 ``ValidationError``400
"""
if value is None:
return None
if isinstance(value, ChannelType):
return value
try:
if isinstance(value, str) and value:
return ChannelType(value)
except ValueError as exc:
raise ValidationError(
"channel_type",
f"invalid channel_type: {value!r}",
trace_id=trace_id,
) from exc
raise ValidationError(
"channel_type",
f"invalid channel_type: {value!r}",
trace_id=trace_id,
)
def _coerceAccountStatus(value: Any, trace_id: str | None) -> AccountStatus | None:
@ -299,10 +297,7 @@ def _batchUpdateConfigResultToDict(result: Any) -> dict[str, Any]:
return {
"total": result.total,
"succeeded": [{"key": item.key, "new_version": item.new_version} for item in result.succeeded],
"failed": [
{"id": f.id, "error_code": f.error_code, "message": f.message}
for f in result.failed
],
"failed": [{"id": f.id, "error_code": f.error_code, "message": f.message} for f in result.failed],
}
@ -316,7 +311,7 @@ def _channelSessionToDict(session: Any) -> dict[str, Any]:
"""
return {
"session_id": session.session_id,
"channel_type": session.channel_type.value if session.channel_type else None,
"channel_type": session.channel_type if session.channel_type else None,
"account_id": session.account_id,
"peer_id": session.peer_id,
"chat_type": session.chat_type,
@ -375,7 +370,7 @@ def _messageSearchItemToDict(item: MessageSearchItem) -> dict[str, Any]:
"message_id": item.message_id,
"conversation_id": item.conversation_id,
"channel_session_id": item.channel_session_id,
"channel_type": item.channel_type.value if item.channel_type else None,
"channel_type": item.channel_type if item.channel_type else None,
"role": item.role,
"content": item.content,
"snippet": item.snippet,
@ -397,7 +392,7 @@ def _pairingRecordToDict(record: PairingRecord) -> dict[str, Any]:
return {
"pairing_id": record.pairing_id,
"channel_account_id": record.channel_account_id,
"channel_type": record.channel_type.value if record.channel_type else None,
"channel_type": record.channel_type if record.channel_type else None,
"peer_id": record.peer_id,
"peer_name": record.peer_name,
"status": record.status.value,
@ -564,7 +559,7 @@ def _buildExportFilename(
"""
from datetime import datetime as _dt
channel = channel_type.value if hasattr(channel_type, "value") else str(channel_type)
channel = channel_type if hasattr(channel_type, "value") else str(channel_type)
date_str = _dt.now().strftime("%Y%m%d")
return f"allowlist_{channel}_{account_id}_{policy_type.value}_{date_str}.csv"
@ -683,7 +678,7 @@ def _auditQueryToDict(query: AuditQuery) -> dict[str, Any]:
return {
"operation_type": query.operation_type.value if query.operation_type else None,
"operator": query.operator,
"target_channel": query.target_channel.value if query.target_channel else None,
"target_channel": query.target_channel if query.target_channel else None,
"target_account": query.target_account,
"start_time": query.start_time.isoformat() if query.start_time else None,
"end_time": query.end_time.isoformat() if query.end_time else None,

View File

@ -326,7 +326,7 @@ class AgentRunEnqueueStage:
await self.logger.warn(
"channel context provider not registered, skip context note generation",
trace_id=context.trace_id,
channel_type=context.channel_type.value,
channel_type=context.channel_type,
account_id=context.account_id,
)
return run_context
@ -341,7 +341,7 @@ class AgentRunEnqueueStage:
)
plugin_context: dict[str, Any] = {
"channel_type": context.channel_type.value,
"channel_type": context.channel_type,
"account_id": context.account_id,
"peer_id": context.peer_id,
"chat_type": context.chat_type,
@ -368,7 +368,7 @@ class AgentRunEnqueueStage:
await self.logger.warn(
f"context note enrichment failed, skip: {exc}",
trace_id=context.trace_id,
channel_type=context.channel_type.value,
channel_type=context.channel_type,
account_id=context.account_id,
)
context.degraded = True
@ -418,7 +418,7 @@ class AgentRunEnqueueStage:
await self.logger.warn(
"tools adapter not registered, skip channel tools enrichment",
trace_id=context.trace_id,
channel_type=context.channel_type.value,
channel_type=context.channel_type,
account_id=context.account_id,
)
return run_context
@ -432,7 +432,7 @@ class AgentRunEnqueueStage:
await self.logger.warn(
f"getChannelTools failed, fallback to empty tool list: {exc}",
trace_id=context.trace_id,
channel_type=context.channel_type.value,
channel_type=context.channel_type,
account_id=context.account_id,
)
context.degraded = True
@ -488,7 +488,7 @@ class AgentRunEnqueueStage:
await self.logger.warn(
"message ops adapter not registered, skip message ops enrichment",
trace_id=context.trace_id,
channel_type=context.channel_type.value,
channel_type=context.channel_type,
account_id=context.account_id,
)
return run_context
@ -502,7 +502,7 @@ class AgentRunEnqueueStage:
await self.logger.warn(
f"getMessageOperations failed, skip message ops tools: {exc}",
trace_id=context.trace_id,
channel_type=context.channel_type.value,
channel_type=context.channel_type,
account_id=context.account_id,
)
context.degraded = True
@ -599,7 +599,7 @@ class AgentRunEnqueueStage:
await self.logger.warn(
f"config read failed, fallback to default: key={key}, default={default}, error={exc}",
trace_id=context.trace_id,
channel_type=context.channel_type.value,
channel_type=context.channel_type,
account_id=context.account_id,
)
context.degraded = True

View File

@ -146,7 +146,7 @@ class ClassifyStage:
if session_adapter is None:
raise ValidationError(
"session_adapter",
f"session adapter not registered for channel {context.channel_type.value}",
f"session adapter not registered for channel {context.channel_type}",
trace_id=context.trace_id,
)
context.peer_id = await session_adapter.getPeerId(context.raw_event)
@ -158,7 +158,7 @@ class ClassifyStage:
if inbound_adapter is None:
raise ValidationError(
"inbound_adapter",
f"inbound adapter not registered for channel {context.channel_type.value}",
f"inbound adapter not registered for channel {context.channel_type}",
trace_id=context.trace_id,
)
context.message_content = await inbound_adapter.normalizeInbound(context.raw_event)
@ -167,7 +167,7 @@ class ClassifyStage:
if mention_adapter is None:
raise ValidationError(
"mention_adapter",
f"mention adapter not registered for channel {context.channel_type.value}",
f"mention adapter not registered for channel {context.channel_type}",
trace_id=context.trace_id,
)
mention_context = await mention_adapter.classifyMention(context.raw_event)
@ -181,7 +181,7 @@ class ClassifyStage:
await self.logger.info(
"implicit mention kind uncertain, classified as native",
trace_id=context.trace_id,
channel_type=context.channel_type.value,
channel_type=context.channel_type,
account_id=context.account_id,
)
mention_context = self._mention_classifier.classify(mention_context)

View File

@ -142,7 +142,7 @@ class MediaFetchStage:
await self.logger.info(
"attachment type not supported in v1, skip",
trace_id=context.trace_id,
channel_type=context.channel_type.value,
channel_type=context.channel_type,
attachment_type=attachment.type,
)
return None
@ -175,7 +175,7 @@ class MediaFetchStage:
await self.logger.warn(
"adapter not implemented downloadAttachment, skip download",
trace_id=context.trace_id,
channel_type=context.channel_type.value,
channel_type=context.channel_type,
account_id=context.account_id,
)
return None # 附件被剔除
@ -189,7 +189,7 @@ class MediaFetchStage:
await self.logger.warn(
"downloadAttachment raised exception, skip attachment",
trace_id=context.trace_id,
channel_type=context.channel_type.value,
channel_type=context.channel_type,
error=str(exc),
)
context.degraded = True
@ -205,7 +205,7 @@ class MediaFetchStage:
await self.logger.warn(
"downloadAttachment returned None, skip attachment",
trace_id=context.trace_id,
channel_type=context.channel_type.value,
channel_type=context.channel_type,
)
context.degraded = True
return None
@ -219,7 +219,7 @@ class MediaFetchStage:
await self.logger.warn(
"image size exceeds limit, skip attachment",
trace_id=context.trace_id,
channel_type=context.channel_type.value,
channel_type=context.channel_type,
size=len(content),
limit=self.MAX_ATTACHMENT_SIZE,
)
@ -237,7 +237,7 @@ class MediaFetchStage:
await self.logger.warn(
"image_processor failed, skip attachment",
trace_id=context.trace_id,
channel_type=context.channel_type.value,
channel_type=context.channel_type,
error=str(exc),
)
context.degraded = True
@ -252,7 +252,7 @@ class MediaFetchStage:
await self.logger.warn(
"image_processor returned failure, skip attachment",
trace_id=context.trace_id,
channel_type=context.channel_type.value,
channel_type=context.channel_type,
error=result.get("error", "unknown"),
)
context.degraded = True

View File

@ -93,7 +93,7 @@ class ReceiveStage:
plugin_state = self.plugin_registry.getPluginState(plugin_manifest.manifest.id)
if plugin_state is LifecycleState.FAILED:
raise ChannelDegradedError(
context.channel_type.value,
context.channel_type,
trace_id=context.trace_id,
)
return True

View File

@ -200,7 +200,7 @@ class ReplyStage:
"""
payload_json = json.dumps(context.raw_event.payload, sort_keys=True, default=_jsonDefault)
payload_hash = hashlib.sha256(payload_json.encode()).hexdigest()[:16]
return f"{context.channel_type.value}:{context.account_id}:{payload_hash}"
return f"{context.channel_type}:{context.account_id}:{payload_hash}"
async def _getPluginAckPolicy(self, channel_type: ChannelType) -> AckPolicy | None:
"""查询插件声明的 ACK 策略。

View File

@ -104,7 +104,7 @@ class RouteStage:
unified_identity_id = context.unified_identity.identity_id if context.unified_identity is not None else None
binding_ctx = BindingContext(
session_key=f"{context.channel_type.value}:{context.account_id}:{context.peer_id}",
session_key=f"{context.channel_type}:{context.account_id}:{context.peer_id}",
channel_type=context.channel_type,
account_id=context.account_id,
chat_type=context.chat_type,

View File

@ -185,7 +185,7 @@ class SecurityStage:
"dm security decision made",
dm_decision=context.dm_decision.value,
pairing_id=context.pairing_id,
channel_type=context.channel_type.value,
channel_type=context.channel_type,
account_id=context.account_id,
trace_id=context.trace_id,
)
@ -258,14 +258,14 @@ class SecurityStage:
target=context.peer_id or "",
result="success",
params_summary={
"channel_type": context.channel_type.value,
"channel_type": context.channel_type,
"account_id": context.account_id,
"peer_id": context.peer_id,
"policy": dm_policy.value,
"decision": dm_decision.value,
},
trace_id=context.trace_id,
target_channel=context.channel_type.value,
target_channel=context.channel_type,
target_account=context.account_id,
)
await self.audit_log_repository.saveAuditLog(cmd, tx=context.tx)

View File

@ -88,7 +88,7 @@ class ServiceAccountResolveStage:
)
if service_account is None:
raise ServiceAccountNotFoundError(
context.channel_type.value,
context.channel_type,
context.account_id,
trace_id=context.trace_id,
)

View File

@ -166,7 +166,7 @@ class SessionResolveStage:
strategy = await self._readCrossChannelIdentityStrategy(context)
unified_identity_id, _ = self._cross_channel_association_policy.resolve(context.unified_identity, strategy)
lock_key = f"session:{context.channel_type.value}:{context.account_id}:{context.peer_id}"
lock_key = f"session:{context.channel_type}:{context.account_id}:{context.peer_id}"
lock_token = await self.cache_port.acquireAdvisoryLock(lock_key)
try:
session = await self.persistence_port.getChannelSessionByPeer(
@ -232,7 +232,7 @@ class SessionResolveStage:
"malformed cron session key, defaulting to not temporary",
trace_id=context.trace_id,
peer_id=context.peer_id,
channel_type=context.channel_type.value,
channel_type=context.channel_type,
account_id=context.account_id,
)
@ -306,7 +306,7 @@ class SessionResolveStage:
"cross_channel_identity_strategy read failed, fallback to isolation",
error=str(exc),
trace_id=context.trace_id,
channel_type=context.channel_type.value,
channel_type=context.channel_type,
account_id=context.account_id,
)
context.degraded = True

View File

@ -99,7 +99,7 @@ class SignatureVerifyStage:
if adapter is None:
raise ValidationError(
"channel_type",
f"inbound adapter not registered for channel_type={context.channel_type.value}",
f"inbound adapter not registered for channel_type={context.channel_type}",
trace_id=context.trace_id,
)
@ -112,7 +112,7 @@ class SignatureVerifyStage:
await self.logger.warn(
"signature verification skipped: adapter not implemented",
trace_id=context.trace_id,
channel_type=context.channel_type.value,
channel_type=context.channel_type,
account_id=context.account_id,
)
return True

View File

@ -17,7 +17,6 @@ from yuxi.channels.contract.ports.driven.config_port import ConfigPort
from yuxi.channels.core.registry.plugin_registry import PluginRegistry
from yuxi.channels.core.service.capability_verifier import CapabilityVerifier
__all__ = ["CapabilityVerifyStage"]

View File

@ -34,7 +34,6 @@ from yuxi.channels.core.model.outbox_entry import OutboxEntry as OutboxAggregate
from yuxi.channels.core.registry.plugin_registry import PluginRegistry
from yuxi.channels.core.service.bot_loop_budget_guard import BotLoopBudgetGuard
__all__ = ["DeliverStage"]
@ -176,7 +175,7 @@ class DeliverStage:
plugin_state = self.plugin_registry.getPluginState(plugin_manifest.manifest.id)
if plugin_state is LifecycleState.FAILED:
raise ChannelDegradedError(
context.channel_type.value,
context.channel_type,
trace_id=context.trace_id,
)
@ -184,7 +183,7 @@ class DeliverStage:
if adapter is None:
raise ValidationError(
field="channel_type",
message=f"outbound adapter not registered for channel type: {context.channel_type.value}",
message=f"outbound adapter not registered for channel type: {context.channel_type}",
trace_id=context.trace_id,
)
@ -193,7 +192,7 @@ class DeliverStage:
await self.logger.warn(
"outbound adapter does not support outbound, skip deliver stage",
trace_id=context.trace_id,
channel_type=context.channel_type.value,
channel_type=context.channel_type,
account_id=context.account_id,
)
return False

View File

@ -18,7 +18,6 @@ from yuxi.channels.contract.ports.driven.conversation_port import ConversationPo
from yuxi.channels.contract.ports.driven.logger_port import LoggerPort
from yuxi.channels.core.service.fence_guard import FenceGuard
__all__ = ["FenceCheckStage"]
@ -115,7 +114,7 @@ class FenceCheckStage:
conversation_id=context.conversation_id,
stale_generation=verdict.stale_generation,
current_generation=verdict.current_generation,
channel_type=context.channel_type.value,
channel_type=context.channel_type,
account_id=context.account_id,
)
return False

View File

@ -23,7 +23,6 @@ from yuxi.channels.contract.plugin.extension_point import FailureStrategy
from yuxi.channels.contract.ports.driven.config_port import ConfigPort
from yuxi.channels.contract.ports.driven.logger_port import LoggerPort
__all__ = ["FormatStage"]
@ -182,7 +181,7 @@ class FormatStage:
"proof failed, degrade to fallback format "
"(manifest-runtime inconsistency)",
trace_id=context.trace_id,
channel_type=context.channel_type.value,
channel_type=context.channel_type,
capability=cap_result.capability,
)
return None, None
@ -199,7 +198,7 @@ class FormatStage:
await self.logger.warn(
"rich message render failed, degrade to markdown",
trace_id=context.trace_id,
channel_type=context.channel_type.value,
channel_type=context.channel_type,
error=str(exc),
)
context.degraded = True
@ -214,7 +213,7 @@ class FormatStage:
await self.logger.warn(
"degradeToMarkdown failed, fallback to rich message text",
trace_id=context.trace_id,
channel_type=context.channel_type.value,
channel_type=context.channel_type,
error=str(degrade_exc),
)
context.degraded_reason = degrade_exc

View File

@ -14,7 +14,6 @@ from yuxi.channels.contract.dtos.common import Attachment
from yuxi.channels.contract.dtos.outbound import OutboundPayload, RichMessageFields
from yuxi.channels.contract.plugin.extension_point import FailureStrategy
__all__ = ["LoadBuildStage"]

View File

@ -74,7 +74,6 @@ from yuxi.channels.core.service.fence_guard import FenceGuard
from yuxi.channels.core.service.truncation_detector import TruncationDetector
from yuxi.channels.core.service.trusted_message_guard import TrustedMessageGuard
__all__ = ["OutboundPipeline"]

View File

@ -21,7 +21,6 @@ from yuxi.channels.contract.ports.driven.event_publisher_port import EventPublis
from yuxi.channels.contract.ports.driven.logger_port import LoggerPort
from yuxi.channels.contract.ports.driven.outbox_repository_port import OutboxRepositoryPort
__all__ = ["OutboxMarkFailed"]

View File

@ -37,7 +37,6 @@ from yuxi.channels.contract.ports.driven.logger_port import LoggerPort
from yuxi.channels.contract.ports.driven.outbox_repository_port import OutboxRepositoryPort
from yuxi.channels.contract.ports.driven.transaction_port import TransactionPort
__all__ = ["OutboxPersistStage"]

View File

@ -21,7 +21,6 @@ from yuxi.channels.contract.ports.driven.event_publisher_port import EventPublis
from yuxi.channels.contract.ports.driven.logger_port import LoggerPort
from yuxi.channels.contract.ports.driven.outbox_repository_port import OutboxRepositoryPort
__all__ = ["OutboxRollback"]

View File

@ -13,7 +13,6 @@ from yuxi.channels.application.context.outbound_context import OutboundContext
from yuxi.channels.contract.dtos.outbound import FinalMessage
from yuxi.channels.contract.plugin.extension_point import FailureStrategy
__all__ = ["PrefixStage"]

View File

@ -18,7 +18,6 @@ from yuxi.channels.contract.ports.driven.conversation_port import ConversationPo
from yuxi.channels.contract.ports.driven.logger_port import LoggerPort
from yuxi.channels.core.service.fence_guard import FenceGuard
__all__ = ["StatusWritebackStage"]

View File

@ -44,7 +44,6 @@ from yuxi.channels.contract.ports.driven.agent_run_port import AgentRunPort
from yuxi.channels.contract.ports.driven.config_port import ConfigPort
from yuxi.channels.contract.ports.driven.logger_port import LoggerPort
__all__ = ["StreamChunkStage"]
@ -142,7 +141,7 @@ class StreamChunkStage:
context.delivery_mode = "persistent"
context.degraded = True
context.degraded_reason = ChannelDegradedError(
context.channel_type.value,
context.channel_type,
trace_id=context.trace_id,
)
return True
@ -164,7 +163,7 @@ class StreamChunkStage:
f"ttl={ttl_s:.2f}s), stop sending chunks "
f"(sent={len(results)})",
trace_id=context.trace_id,
channel_type=context.channel_type.value,
channel_type=context.channel_type,
account_id=context.account_id,
)
break
@ -186,7 +185,7 @@ class StreamChunkStage:
context.chunk_results = results
context.delivery_mode = "persistent"
raise ChannelDegradedError(
context.channel_type.value,
context.channel_type,
trace_id=context.trace_id,
) from exc

View File

@ -24,7 +24,6 @@ from yuxi.channels.contract.ports.driven.agent_run_port import AgentRunPort
from yuxi.channels.contract.ports.driven.logger_port import LoggerPort
from yuxi.channels.core.service.truncation_detector import TruncationDetector
__all__ = ["TruncationCheckStage"]
@ -129,7 +128,7 @@ class TruncationCheckStage:
f"truncation completed: original_length={original_length} completed_length={completed_length}",
trace_id=context.trace_id,
agent_run_id=context.agent_run_id,
channel_type=context.channel_type.value,
channel_type=context.channel_type,
account_id=context.account_id,
)
return True
@ -141,7 +140,7 @@ class TruncationCheckStage:
f"original_length={original_length} completed_length={completed_length}",
trace_id=context.trace_id,
agent_run_id=context.agent_run_id,
channel_type=context.channel_type.value,
channel_type=context.channel_type,
account_id=context.account_id,
)
return True

View File

@ -17,7 +17,6 @@ from yuxi.channels.contract.ports.driven.conversation_port import ConversationPo
from yuxi.channels.contract.ports.driven.logger_port import LoggerPort
from yuxi.channels.core.service.trusted_message_guard import TrustedMessageGuard
__all__ = ["TrustedInjectStage"]

View File

@ -21,7 +21,6 @@ from yuxi.channels.contract.plugin.extension_point import FailureStrategy
from yuxi.channels.contract.ports.driven.config_port import ConfigPort
from yuxi.channels.contract.ports.driven.logger_port import LoggerPort
__all__ = ["TypingIndicatorStage"]
@ -126,7 +125,7 @@ class TypingIndicatorStage:
await self.logger.warn(
"startTypingIndicator returned False, typing indicator not started",
trace_id=context.trace_id,
channel_type=context.channel_type.value,
channel_type=context.channel_type,
account_id=context.account_id,
)
except Error as exc:
@ -137,7 +136,7 @@ class TypingIndicatorStage:
await self.logger.warn(
f"startTypingIndicator failed: {exc!r}",
trace_id=context.trace_id,
channel_type=context.channel_type.value,
channel_type=context.channel_type,
account_id=context.account_id,
)
context.degraded = True

View File

@ -22,7 +22,6 @@ from yuxi.channels.contract.plugin.adapters.streaming_adapter import StreamingAd
from yuxi.channels.contract.plugin.extension_point import FailureStrategy
from yuxi.channels.contract.ports.driven.logger_port import LoggerPort
__all__ = ["TypingStopStage"]
@ -112,7 +111,7 @@ class TypingStopStage:
await self.logger.warn(
f"endStreaming failed: {exc!r}",
trace_id=context.trace_id,
channel_type=context.channel_type.value,
channel_type=context.channel_type,
account_id=context.account_id,
)
context.degraded = True
@ -127,7 +126,7 @@ class TypingStopStage:
await self.logger.warn(
"stopTypingIndicator returned False, typing indicator resource may leak",
trace_id=context.trace_id,
channel_type=context.channel_type.value,
channel_type=context.channel_type,
account_id=context.account_id,
)
except Error as exc:
@ -138,7 +137,7 @@ class TypingStopStage:
await self.logger.warn(
f"stopTypingIndicator failed: {exc!r}",
trace_id=context.trace_id,
channel_type=context.channel_type.value,
channel_type=context.channel_type,
account_id=context.account_id,
)
context.degraded = True

View File

@ -15,7 +15,6 @@ from yuxi.channels.contract.errors import DmDeniedError
from yuxi.channels.contract.plugin.extension_point import FailureStrategy
from yuxi.channels.core.registry.whitelist_registry import WhitelistRegistry
__all__ = ["WhitelistCheckStage"]

View File

@ -107,7 +107,7 @@ class BaseTransportWorker(ABC):
@staticmethod
def _make_account_key(channel_type: ChannelType, account_id: str) -> str:
return f"{channel_type.value}:{account_id}"
return f"{channel_type}:{account_id}"
def _touch(self, account_key: str) -> None:
task_info = self._tasks.get(account_key)
@ -221,7 +221,7 @@ class BaseTransportWorker(ABC):
if await self._circuit_breaker.isOpen(channel_type, account_id):
await self._logger.warn(
"circuit breaker open, skip starting account",
channel_type=channel_type.value,
channel_type=channel_type,
account_id=account_id,
transport_mode=self.transport_mode,
)
@ -234,7 +234,7 @@ class BaseTransportWorker(ABC):
adapter=adapter,
task=asyncio.create_task(
self._runAccountLoopWrapper(channel_type, account_id, adapter, account_ct),
name=f"transport-{self.transport_mode}-{channel_type.value}-{account_id}",
name=f"transport-{self.transport_mode}-{channel_type}-{account_id}",
),
cancellation_token=account_ct,
state="running",
@ -246,7 +246,7 @@ class BaseTransportWorker(ABC):
await self._logger.info(
"account transport task started",
channel_type=channel_type.value,
channel_type=channel_type,
account_id=account_id,
transport_mode=self.transport_mode,
)
@ -283,7 +283,7 @@ class BaseTransportWorker(ABC):
await self._logger.info(
"account transport task stopped",
channel_type=channel_type.value,
channel_type=channel_type,
account_id=account_id,
transport_mode=self.transport_mode,
reason=reason,
@ -323,7 +323,7 @@ class BaseTransportWorker(ABC):
await self._logger.info(
"account transport loop started",
trace_id=trace_id,
channel_type=channel_type.value,
channel_type=channel_type,
account_id=account_id,
transport_mode=self.transport_mode,
)
@ -334,7 +334,7 @@ class BaseTransportWorker(ABC):
await self._logger.error(
"account transport loop crashed",
trace_id=trace_id,
channel_type=channel_type.value,
channel_type=channel_type,
account_id=account_id,
transport_mode=self.transport_mode,
error=str(exc),
@ -346,7 +346,7 @@ class BaseTransportWorker(ABC):
await self._logger.info(
"account transport loop ended",
trace_id=trace_id,
channel_type=channel_type.value,
channel_type=channel_type,
account_id=account_id,
transport_mode=self.transport_mode,
)
@ -379,14 +379,14 @@ class BaseTransportWorker(ABC):
if is_open:
await self._logger.warn(
"circuit breaker open, not restarting account",
channel_type=channel_type.value,
channel_type=channel_type,
account_id=account_id,
transport_mode=self.transport_mode,
)
return
await self._logger.info(
"restarting account transport after failure",
channel_type=channel_type.value,
channel_type=channel_type,
account_id=account_id,
transport_mode=self.transport_mode,
)
@ -394,7 +394,7 @@ class BaseTransportWorker(ABC):
except Exception as restart_exc:
await self._logger.error(
"failed to restart account transport",
channel_type=channel_type.value,
channel_type=channel_type,
account_id=account_id,
transport_mode=self.transport_mode,
error=str(restart_exc),
@ -403,7 +403,7 @@ class BaseTransportWorker(ABC):
try:
asyncio.create_task(
_restart(),
name=f"transport-restart-{self.transport_mode}-{channel_type.value}-{account_id}",
name=f"transport-restart-{self.transport_mode}-{channel_type}-{account_id}",
)
except RuntimeError:
pass
@ -431,7 +431,7 @@ class BaseTransportWorker(ABC):
except Exception as reset_exc:
await self._logger.warn(
"adapter onTransportReset failed after stall",
channel_type=channel_type.value,
channel_type=channel_type,
account_id=account_id,
transport_mode=self.transport_mode,
error=str(reset_exc),
@ -441,7 +441,7 @@ class BaseTransportWorker(ABC):
if is_open:
await self._logger.warn(
"circuit breaker open after stall, not restarting",
channel_type=channel_type.value,
channel_type=channel_type,
account_id=account_id,
transport_mode=self.transport_mode,
)
@ -450,7 +450,7 @@ class BaseTransportWorker(ABC):
except Exception as exc:
await self._logger.error(
"failed to restart account after stall",
channel_type=channel_type.value,
channel_type=channel_type,
account_id=account_id,
transport_mode=self.transport_mode,
error=str(exc),
@ -497,7 +497,7 @@ class BaseTransportWorker(ABC):
await self._logger.error(
"message delivery failed",
trace_id=trace_id,
channel_type=channel_type.value,
channel_type=channel_type,
account_id=account_id,
transport_mode=self.transport_mode,
error=str(exc),
@ -599,7 +599,7 @@ class BaseTransportWorker(ABC):
await self._logger.error(
"failed to publish TransportErrorOccurred event",
trace_id=trace_id,
channel_type=channel_type.value,
channel_type=channel_type,
account_id=account_id,
transport_mode=self.transport_mode,
error=str(pub_exc),
@ -629,7 +629,7 @@ class BaseTransportWorker(ABC):
await self._logger.info(
"published channel account offline event",
trace_id=trace_id,
channel_type=channel_type.value,
channel_type=channel_type,
account_id=account_id,
transport_mode=self.transport_mode,
reason=reason,
@ -662,7 +662,7 @@ class BaseTransportWorker(ABC):
await self._logger.warn(
"transport error occurred",
trace_id=trace_id,
channel_type=channel_type.value,
channel_type=channel_type,
account_id=account_id,
transport_mode=self.transport_mode,
category=error.category,
@ -681,7 +681,7 @@ class BaseTransportWorker(ABC):
await self._logger.error(
"failed to publish offline event after auth_expired",
trace_id=trace_id,
channel_type=channel_type.value,
channel_type=channel_type,
account_id=account_id,
transport_mode=self.transport_mode,
error=str(pub_exc),
@ -741,7 +741,7 @@ class BaseTransportWorker(ABC):
await self._logger.debug(
"transport backoff wait",
trace_id=trace_id,
channel_type=channel_type.value,
channel_type=channel_type,
account_id=account_id,
transport_mode=self.transport_mode,
category=error.category,
@ -776,7 +776,7 @@ class BaseTransportWorker(ABC):
await self._logger.warn(
"transport stall detected, will restart account",
trace_id=trace_id,
channel_type=task_info.channel_type.value,
channel_type=task_info.channel_type,
account_id=task_info.account_id,
transport_mode=self.transport_mode,
stall_timeout_ms=self._config.stall_timeout_ms,
@ -789,7 +789,7 @@ class BaseTransportWorker(ABC):
task_info.account_id,
task_info.adapter,
),
name=f"transport-stall-restart-{self.transport_mode}-{task_info.channel_type.value}-{task_info.account_id}",
name=f"transport-stall-restart-{self.transport_mode}-{task_info.channel_type}-{task_info.account_id}",
)
def getHealth(self) -> WorkerHealthSnapshot:
@ -806,7 +806,7 @@ class BaseTransportWorker(ABC):
state = ti.state if not ti.task.done() else "stopped"
accounts.append(
AccountHealthSnapshot(
channel_type=ti.channel_type.value,
channel_type=ti.channel_type,
account_id=ti.account_id,
state=state,
last_activity_at=ti.last_activity_at,

View File

@ -148,8 +148,8 @@ class TransportManager:
await self._logger.info(
"transport manager started",
trace_id=trace_id,
puller_channels=[ct.value for ct in puller_registry.keys()],
stream_channels=[ct.value for ct in stream_connector_registry.keys()],
puller_channels=[ct for ct in puller_registry.keys()],
stream_channels=[ct for ct in stream_connector_registry.keys()],
)
async def stop(self, timeout: float = 5.0) -> None:
@ -326,7 +326,7 @@ class TransportManager:
await self._logger.info(
"received account online event, starting transport",
trace_id=trace_id,
channel_type=channel_type.value,
channel_type=channel_type,
account_id=account_id,
)
@ -365,7 +365,7 @@ class TransportManager:
await self._logger.info(
"received account offline event, stopping transport",
trace_id=trace_id,
channel_type=channel_type.value,
channel_type=channel_type,
account_id=account_id,
reason=reason,
)

View File

@ -149,7 +149,7 @@ class PullerWorker(BaseTransportWorker):
await self._logger.error(
"failed to deliver inbound message",
trace_id=error_trace_id,
channel_type=channel_type.value,
channel_type=channel_type,
account_id=account_id,
transport_mode=self.transport_mode,
error=str(e),
@ -172,7 +172,7 @@ class PullerWorker(BaseTransportWorker):
await self._logger.warn(
"failed to persist transport cursor",
trace_id=error_trace_id,
channel_type=channel_type.value,
channel_type=channel_type,
account_id=account_id,
cursor=cursor,
error=str(cursor_exc),

View File

@ -104,7 +104,7 @@ class StreamWorker(BaseTransportWorker):
await self._logger.info(
"stream connection established",
trace_id=trace_id,
channel_type=channel_type.value,
channel_type=channel_type,
account_id=account_id,
transport_mode=self.transport_mode,
)
@ -146,7 +146,7 @@ class StreamWorker(BaseTransportWorker):
heartbeat_interval_s,
cancellation_token,
),
name=f"stream-heartbeat-{channel_type.value}-{account_id}",
name=f"stream-heartbeat-{channel_type}-{account_id}",
)
# 3. 接收循环
@ -223,7 +223,7 @@ class StreamWorker(BaseTransportWorker):
await self._logger.warn(
"stream receive error, will reconnect",
trace_id=trace_id,
channel_type=channel_type.value,
channel_type=channel_type,
account_id=account_id,
transport_mode=self.transport_mode,
error=str(exc),
@ -238,7 +238,7 @@ class StreamWorker(BaseTransportWorker):
await self._logger.error(
"failed to deliver stream message",
trace_id=trace_id,
channel_type=channel_type.value,
channel_type=channel_type,
account_id=account_id,
transport_mode=self.transport_mode,
error=str(exc),
@ -277,7 +277,7 @@ class StreamWorker(BaseTransportWorker):
await self._logger.warn(
"stream heartbeat failed",
trace_id=trace_id,
channel_type=channel_type.value,
channel_type=channel_type,
account_id=account_id,
transport_mode=self.transport_mode,
error=str(exc),

View File

@ -42,6 +42,7 @@ from yuxi.channels.contract.errors import (
DependencyError,
DmDeniedError,
Error,
NotFoundError,
RateLimitError,
ValidationError,
)
@ -292,7 +293,7 @@ class AdminMessageService:
for target in self._parseTargets(cmd.target):
channel_type, _, _ = self._resolveTarget(target)
if channel_type is not None:
target_channel = channel_type.value
target_channel = str(channel_type)
break
has_failures = bool(result.failures)
@ -432,7 +433,7 @@ class AdminMessageService:
error_code="VALIDATION_ERROR",
message=(
f"message length {len(cmd.content.text)} exceeds max "
f"{max_length} for channel {channel_type.value}"
f"{max_length} for channel {channel_type}"
),
retryable=False,
)
@ -449,6 +450,8 @@ class AdminMessageService:
policy,
target_type=cmd.target_type,
)
except NotFoundError:
raise
except Exception as e:
await self._logger.error(
f"session create failed for target {target}: {e}",
@ -648,7 +651,8 @@ class AdminMessageService:
支持格式
- ``channel_type:account_id:session_id``完整路由信息
- ``session_id``仅会话 IDchannel_type 置为 CUSTOMaccount_id 置空
- ``session_id``仅会话 IDchannel_type account_id 置空
视为格式非法返回 None
``split(":", 2)`` 限制分割次数为 2确保 ``session_id`` 中含冒号
时不会被截断部分渠道的会话 ID ``:`` 分隔符
@ -658,18 +662,16 @@ class AdminMessageService:
返回
元组 (channel_type, account_id, session_id)channel_type
None 表示目标格式非法channel_type 不是合法的渠道类型
None 表示目标格式非法缺少 channel_type 段或 channel_type 段为空
"""
parts = target.split(":", 2)
if len(parts) < 3:
return ChannelType.CUSTOM, "", target
return None, "", target
channel_type_str, account_id, session_id = parts[0], parts[1], parts[2]
try:
channel_type = ChannelType(channel_type_str)
except ValueError:
if not channel_type_str:
return None, "", ""
return channel_type, account_id, session_id
return ChannelType(channel_type_str), account_id, session_id
def _toFailureDetail(self, target: str, err: Error | None) -> FailureDetail:
"""将管道错误转换为失败详情。

View File

@ -123,8 +123,8 @@ from yuxi.channels.contract.errors import (
Error,
InternalError,
NotFoundError,
RuleViolationError,
OperationTimeoutError,
RuleViolationError,
ValidationError,
)
from yuxi.channels.contract.ports.driven.channel_session_repository_port import (
@ -357,9 +357,7 @@ class ChannelControlService:
)
# 批量操作部分成功时返回 partial 状态,供 raiseOnControlFailure
# 放行并由 router 在响应 envelope 中准确反映部分成功语义。
status: Literal["success", "partial"] = (
"partial" if ctx.dispatch_partial else "success"
)
status: Literal["success", "partial"] = "partial" if ctx.dispatch_partial else "success"
return ControlResult(
status=status,
data=ctx.dispatch_result,
@ -552,11 +550,7 @@ class ChannelControlService:
``doctor/checks/{id}/repair/preview`` ``confirmed=True``写操作
``doctor/checks/{id}/repair`` 拆分确保只读预览不被审计为写操作
"""
operation = (
f"doctor/checks/{check_id}/repair/preview"
if not confirmed
else f"doctor/checks/{check_id}/repair"
)
operation = f"doctor/checks/{check_id}/repair/preview" if not confirmed else f"doctor/checks/{check_id}/repair"
cmd = ControlCmd(
operation=operation,
operator=operator,
@ -1111,9 +1105,9 @@ class ChannelControlService:
"""
params: dict[str, Any] = {"granularity": query.granularity}
if query.start_time is not None:
params["start_time"] = query.start_time
params["start_time"] = query.start_time.isoformat()
if query.end_time is not None:
params["end_time"] = query.end_time
params["end_time"] = query.end_time.isoformat()
cmd = ControlCmd(
operation="pairing/stats",
operator=operator,
@ -1754,9 +1748,9 @@ class ChannelControlService:
if query.target_account is not None:
params["target_account"] = query.target_account
if query.start_time is not None:
params["start_time"] = query.start_time
params["start_time"] = query.start_time.isoformat()
if query.end_time is not None:
params["end_time"] = query.end_time
params["end_time"] = query.end_time.isoformat()
if query.trace_id is not None:
params["trace_id"] = query.trace_id
cmd = ControlCmd(
@ -1779,9 +1773,9 @@ class ChannelControlService:
if query.target_account is not None:
params["target_account"] = query.target_account
if query.start_time is not None:
params["start_time"] = query.start_time
params["start_time"] = query.start_time.isoformat()
if query.end_time is not None:
params["end_time"] = query.end_time
params["end_time"] = query.end_time.isoformat()
cmd = ControlCmd(
operation="audit/stats",
operator=operator,
@ -1813,9 +1807,9 @@ class ChannelControlService:
if query.target_account is not None:
params["target_account"] = query.target_account
if query.start_time is not None:
params["start_time"] = query.start_time
params["start_time"] = query.start_time.isoformat()
if query.end_time is not None:
params["end_time"] = query.end_time
params["end_time"] = query.end_time.isoformat()
if query.trace_id is not None:
params["trace_id"] = query.trace_id
cmd = ControlCmd(
@ -2418,7 +2412,7 @@ class ChannelControlService:
"filter_role": "admin",
}
if channel_type is not None:
params["channel_type"] = channel_type.value
params["channel_type"] = channel_type
if start_time is not None:
params["start_time"] = start_time.isoformat()
if end_time is not None:
@ -2505,7 +2499,7 @@ class ChannelControlService:
"""全文搜索消息MSG-SEARCH-01"""
params: dict[str, Any] = {"keyword": keyword, "limit": limit, "offset": offset}
if channel_type is not None:
params["channel_type"] = channel_type.value
params["channel_type"] = channel_type
if channel_session_id is not None:
params["channel_session_id"] = channel_session_id
if peer_id is not None:
@ -2914,7 +2908,7 @@ class ChannelControlService:
"""获取 outbox 统计信息OBX-02"""
params: dict[str, Any] = {}
if channel_type is not None:
params["channel_type"] = channel_type.value
params["channel_type"] = channel_type
cmd = ControlCmd(
operation="outbox/stats",
operator=operator,
@ -3036,7 +3030,7 @@ class ChannelControlService:
"""
params: dict[str, Any] = {"format": cmd.format, "limit": cmd.limit}
if cmd.channel_type is not None:
params["channel_type"] = cmd.channel_type.value
params["channel_type"] = cmd.channel_type
if cmd.created_after is not None:
params["created_after"] = cmd.created_after
if cmd.created_before is not None:
@ -3067,13 +3061,13 @@ class ChannelControlService:
operator: 操作人审计用
"""
params: dict[str, Any] = {
"start_time": query.start_time,
"end_time": query.end_time,
"start_time": query.start_time.isoformat() if query.start_time else None,
"end_time": query.end_time.isoformat() if query.end_time else None,
"granularity": query.granularity,
"metric": query.metric,
}
if query.channel_type is not None:
params["channel_type"] = query.channel_type.value
params["channel_type"] = query.channel_type
cmd = ControlCmd(
operation="outbox/trend",
operator=operator,
@ -3128,7 +3122,7 @@ class ChannelControlService:
构造 ``operation="content_review/preview"`` ``ControlCmd``委托
``_executeControl`` 走控制面管道auth permission rate_limit
dispatch audit``params["target"]`` 显式设置为
``f"{channel_type.value}:{account_id}"``
``f"{channel_type}:{account_id}"``
``AuditContextBuilder._computeTarget`` 读取确保审计日志的
``target`` 字段携带账户上下文 ``directory/*`` / ``login/*``
多段 target 风格一致
@ -3148,7 +3142,7 @@ class ChannelControlService:
"peer_id": peer_id,
# AuditContextBuilder._computeTarget 读取 params["target"]
# 构造 target=f"{channel_type}:{account_id}"
"target": f"{channel_type.value}:{account_id}",
"target": f"{channel_type}:{account_id}",
},
)
return await self._executeControl(cmd)
@ -3170,9 +3164,9 @@ class ChannelControlService:
if query.account_id is not None:
params["account_id"] = query.account_id
if query.start_time is not None:
params["start_time"] = query.start_time
params["start_time"] = query.start_time.isoformat()
if query.end_time is not None:
params["end_time"] = query.end_time
params["end_time"] = query.end_time.isoformat()
cmd = ControlCmd(
operation="content_review/stats",
operator=operator,
@ -3234,7 +3228,7 @@ class ChannelControlService:
"""查询账户总览DSB-02"""
params: dict[str, Any] = {}
if channel_type is not None:
params["channel_type"] = channel_type.value
params["channel_type"] = channel_type
return await self._executeControl(
ControlCmd(
operation="dashboard/accounts",
@ -3254,7 +3248,7 @@ class ChannelControlService:
"""查询消息总览DSB-03"""
params: dict[str, Any] = {}
if channel_type is not None:
params["channel_type"] = channel_type.value
params["channel_type"] = channel_type
if start_time is not None:
params["start_time"] = start_time
if end_time is not None:
@ -3276,7 +3270,7 @@ class ChannelControlService:
"""查询会话总览DSB-04"""
params: dict[str, Any] = {}
if channel_type is not None:
params["channel_type"] = channel_type.value
params["channel_type"] = channel_type
return await self._executeControl(
ControlCmd(
operation="dashboard/sessions",
@ -3296,7 +3290,7 @@ class ChannelControlService:
"""查询投递总览DSB-DELIVERY"""
params: dict[str, Any] = {}
if channel_type is not None:
params["channel_type"] = channel_type.value
params["channel_type"] = channel_type
if start_time is not None:
params["start_time"] = start_time
if end_time is not None:
@ -3318,7 +3312,7 @@ class ChannelControlService:
"""查询实时监控指标DSB-REALTIME"""
params: dict[str, Any] = {"window_seconds": query.window_seconds}
if query.channel_type is not None:
params["channel_type"] = query.channel_type.value
params["channel_type"] = query.channel_type
return await self._executeControl(
ControlCmd(
operation="dashboard/realtime",

View File

@ -29,8 +29,8 @@ from yuxi.channels.contract.errors import (
Error,
InternalError,
NotFoundError,
RateLimitError,
OperationTimeoutError,
RateLimitError,
)
from yuxi.channels.contract.ports.driven.audit_log_repository_port import (
AuditLogRepositoryPort,
@ -132,7 +132,7 @@ class HealthCheckService:
except Error as e:
await self._logger.error(
f"health check failed: {e}",
channel_filter=(query.channel_filter.value if query.channel_filter is not None else None),
channel_filter=(query.channel_filter if query.channel_filter is not None else None),
)
return HealthSnapshot(
status="unhealthy",
@ -285,7 +285,7 @@ class HealthCheckService:
if account is None or not account.enabled:
raise NotFoundError(
resource="channel_account",
id=f"{channel_type.value}:{account_id}",
id=f"{channel_type}:{account_id}",
trace_id=trace_id or operator.request_id,
)
@ -313,10 +313,10 @@ class HealthCheckService:
cmd = SaveAuditLogCmd(
operator=operator.user_id,
operation=AuditOperationType.CHANNEL_PROBED.value,
target=f"{channel_type.value}:{account_id}",
target=f"{channel_type}:{account_id}",
result="success" if result.healthy else "failed",
params_summary={
"channel_type": channel_type.value,
"channel_type": channel_type,
"account_id": account_id,
"healthy": result.healthy,
"latency_ms": result.latency_ms,
@ -325,7 +325,7 @@ class HealthCheckService:
trace_id=trace_id or operator.request_id,
source_ip=operator.ip,
request_id=operator.request_id,
target_channel=channel_type.value,
target_channel=channel_type,
target_account=account_id,
)
try:
@ -335,7 +335,7 @@ class HealthCheckService:
# (探测已执行,副作用已产生,审计失败不应丢弃探测结果)。
await self._logger.warn(
"probe audit log write failed",
channel_type=channel_type.value,
channel_type=channel_type,
account_id=account_id,
error=str(exc),
)
@ -358,7 +358,7 @@ class HealthCheckService:
await self._logger.info(
"diagnostics exported",
operator=request.operator.user_id,
channel_filter=(request.channel_filter.value if request.channel_filter is not None else None),
channel_filter=(request.channel_filter if request.channel_filter is not None else None),
include_audit_logs=request.include_audit_logs,
include_error_logs=request.include_error_logs,
)

View File

@ -53,8 +53,8 @@ from yuxi.channels.contract.errors import (
InternalError,
NotFoundError,
NotImplementedError,
OperationTimeoutError,
)
from yuxi.channels.contract.errors import OperationTimeoutError
from yuxi.channels.contract.plugin.adapters.inbound_adapter import InboundAdapter
from yuxi.channels.contract.ports.driven.channel_account_repository_port import (
ChannelAccountRepositoryPort,
@ -137,7 +137,8 @@ class InboundMessageService:
兜底任务
inbound_adapter_registry: 入站适配器注册表按渠道类型索引
``receiveWebhook`` 校验渠道适配器是否注册WHK-RECV-01
可选未提供时 ``receiveWebhook`` ``NotFoundError``
可选未提供时 ``receiveWebhook`` ``NotImplementedError``
渠道类型未在注册表中时抛 ``NotFoundError``
"""
self._inbound_pipeline = inbound_pipeline
self._outbound_pipeline = outbound_pipeline
@ -238,7 +239,7 @@ class InboundMessageService:
"inbound pipeline crashed",
trace_id=trace_id,
exc_info=e,
channel_type=cmd.channel_type.value,
channel_type=cmd.channel_type,
account_id=cmd.account_id,
)
return InboundResult(
@ -280,9 +281,10 @@ class InboundMessageService:
复用入站管道能力
处理步骤
1. ``inbound_adapter_registry`` 取渠道适配器未注册抛
``NotImplementedError("receive_webhook")``HTTP 501表示
该渠道未实现入站能力
1. ``inbound_adapter_registry`` 取渠道适配器注册表未注入抛
``NotImplementedError("receive_webhook")``HTTP 501表示入站
能力未启用渠道类型未在注册表中抛 ``NotFoundError``HTTP 404
表示该渠道未注册入站适配器
2. 构造 ``RawEvent`` 后调用 ``adapter.extractWebhookResponse`` 检查
是否为 Webhook 配置验证请求如飞书 challenge 握手验证请求
短路返回 ``InboundResult(raw_response=...)``不进入入站管道
@ -306,21 +308,23 @@ class InboundMessageService:
Router 直接作为 HTTP 响应体返回
抛出
NotImplementedError: 渠道适配器未注册registry None 或渠道类型
未在注册表中HTTP 501
NotFoundError: 该渠道类型无启用账户
NotImplementedError: 入站适配器注册表未注入registry None
HTTP 501表示入站能力未启用
NotFoundError: 渠道类型未在注册表中adapter None或该渠道
类型无启用账户HTTP 404
"""
if self._inbound_adapter_registry is None:
raise NotImplementedError(
operation="receive_webhook",
message=f"inbound adapter not registered for channel: {cmd.channel_type.value}",
message=f"inbound adapter not registered for channel: {cmd.channel_type}",
)
adapter = self._inbound_adapter_registry.get(cmd.channel_type)
if adapter is None:
raise NotImplementedError(
operation="receive_webhook",
message=f"inbound adapter not registered for channel: {cmd.channel_type.value}",
raise NotFoundError(
"inbound_adapter",
f"{cmd.channel_type}",
trace_id=cmd.trace_id,
)
raw_event = await self._buildRawEvent(cmd.raw_event, cmd.headers, "webhook", cmd.channel_type)
@ -346,7 +350,7 @@ class InboundMessageService:
if not accounts:
raise NotFoundError(
"channel_account",
f"{cmd.channel_type.value}:<webhook>",
f"{cmd.channel_type}:<webhook>",
)
account_id = accounts[0].account_id
@ -383,23 +387,24 @@ class InboundMessageService:
``valid=False`` ``reason`` 填充失败原因
抛出
NotImplementedError: 渠道适配器未注册registry None 或渠道类型
未在注册表中HTTP 501表示该渠道未实现入站签名校验能力
NotImplementedError: 入站适配器注册表未注入registry None
HTTP 501表示入站签名校验能力未启用
NotFoundError: 渠道类型未在注册表中adapter NoneHTTP 404
"""
if self._inbound_adapter_registry is None:
raise NotImplementedError(
operation="verify_signature",
message=f"inbound adapter not registered for channel: {channel_type.value}",
message=f"inbound adapter not registered for channel: {channel_type}",
)
adapter = self._inbound_adapter_registry.get(channel_type)
if adapter is None:
raise NotImplementedError(
operation="verify_signature",
message=f"inbound adapter not registered for channel: {channel_type.value}",
raise NotFoundError(
"inbound_adapter",
f"{channel_type}",
)
raw_event_obj = await self._buildRawEvent(raw_event, headers, "webhook_verify", channel_type)
raw_event_obj = await self._buildRawEvent(raw_event, headers, "webhook", channel_type)
return await adapter.verifySignature(raw_event_obj)
async def _buildRawEvent(
@ -421,7 +426,7 @@ class InboundMessageService:
except (json.JSONDecodeError, TypeError) as e:
await self._logger.debug(
"webhook raw_event is not valid JSON, wrapping as raw_body",
channel_type=channel_type.value,
channel_type=channel_type,
error=str(e),
)
payload = {"raw_body": raw_event}
@ -492,7 +497,7 @@ class InboundMessageService:
"outbound pipeline crashed during agent response delivery",
trace_id=ctx.trace_id,
agent_run_id=ctx.agent_run_id,
channel_type=ctx.channel_type.value,
channel_type=ctx.channel_type,
account_id=ctx.account_id,
exc_info=e,
)
@ -505,7 +510,7 @@ class InboundMessageService:
f"outbound pipeline failed during agent response delivery: {err.message}",
trace_id=ctx.trace_id,
agent_run_id=ctx.agent_run_id,
channel_type=ctx.channel_type.value,
channel_type=ctx.channel_type,
account_id=ctx.account_id,
)
self._handleOutboundFailure(ctx, err.message)
@ -569,7 +574,7 @@ class InboundMessageService:
await self._logger.exception(
"outbound pipeline crashed during silent command response delivery",
trace_id=ctx.trace_id,
channel_type=ctx.channel_type.value,
channel_type=ctx.channel_type,
account_id=ctx.account_id,
exc_info=e,
)
@ -581,7 +586,7 @@ class InboundMessageService:
await self._logger.error(
f"outbound pipeline failed during silent command response delivery: {err.message}",
trace_id=ctx.trace_id,
channel_type=ctx.channel_type.value,
channel_type=ctx.channel_type,
account_id=ctx.account_id,
)
self._handleOutboundFailure(ctx, err.message)

View File

@ -70,6 +70,7 @@ from yuxi.channels.contract.errors import (
Error,
InternalError,
NotFoundError,
OperationTimeoutError,
PairingExpiredError,
PermissionDeniedError,
PluginAlreadyRegisteredError,
@ -79,7 +80,6 @@ from yuxi.channels.contract.errors import (
RuleViolationError,
SchemaInitializationError,
ServerError,
OperationTimeoutError,
ValidationError,
)

View File

@ -233,6 +233,7 @@ from yuxi.channels.contract.dtos.lifecycle import (
LifecycleCmd,
LifecycleResult,
)
from yuxi.channels.contract.dtos.logger import LogLevel
from yuxi.channels.contract.dtos.login import (
ForceLogoutCmd,
ForceLogoutResult,
@ -240,7 +241,6 @@ from yuxi.channels.contract.dtos.login import (
QrLoginStartResult,
QrLoginWaitResult,
)
from yuxi.channels.contract.dtos.logger import LogLevel
from yuxi.channels.contract.dtos.media import (
MediaDownloadResult,
MediaMetadata,
@ -342,6 +342,10 @@ from yuxi.channels.contract.dtos.route import (
NestedWhitelistDecision,
RouteBinding,
)
from yuxi.channels.contract.dtos.service_account import (
CreateServiceAccountCmd,
ServiceAccount,
)
from yuxi.channels.contract.dtos.session import (
ChannelSessionId,
ChatType,
@ -350,24 +354,12 @@ from yuxi.channels.contract.dtos.session import (
SessionOwner,
TemporarySessionPattern,
)
from yuxi.channels.contract.dtos.service_account import (
CreateServiceAccountCmd,
ServiceAccount,
)
from yuxi.channels.contract.dtos.status import (
EventType,
MessageStatus,
StatusPayload,
)
from yuxi.channels.contract.dtos.stream_event import StreamEvent
from yuxi.channels.contract.dtos.transport import (
PollResult,
PollingConfig,
StreamConfig,
StreamConnection,
TransportConfig,
TransportErrorCategory,
)
from yuxi.channels.contract.dtos.streaming import (
ChunkMode,
ChunkResult,
@ -390,6 +382,14 @@ from yuxi.channels.contract.dtos.trace import (
TraceContext,
TraceId,
)
from yuxi.channels.contract.dtos.transport import (
PollingConfig,
PollResult,
StreamConfig,
StreamConnection,
TransportConfig,
TransportErrorCategory,
)
from yuxi.channels.contract.dtos.truncation import (
TruncationCandidate,
TruncationResult,

View File

@ -15,38 +15,46 @@ from yuxi.channels.contract.dtos.common import BatchOperationFailure, Operator
from yuxi.channels.contract.errors import ValidationError
class ChannelType(StrEnum):
"""渠道类型
class ChannelType(str):
"""渠道类型标识符
标识多渠道网关支持的外部渠道种类用于路由匹配账户管理与插件解析
继承 ``str, Enum`` 以支持 JSON 序列化与字符串比较
取值
FEISHU: 飞书
DINGTALK: 钉钉
WECOM: 企业微信
WEBCHAT: 内置 Web 渠道FR-31
TELEGRAM: Telegram
DISCORD: Discord
WHATSAPP: WhatsApp
CUSTOM: 自定义渠道
由插件 manifest 声明框架层不做白名单校验每个插件应使用唯一的
channel_type ``PluginRegistry`` 保证唯一性运行时为 ``str``
子类可直接作为字典 keyJSON 序列化FastAPI 参数
约束
框架层contract / core / application**禁止** 在任何决策语句中
分支到具体枚举值仅插件自身代码可分支渠道特性决策 **必须**
通过 ``ChannelManifest`` 声明字段驱动 ``critical`` /
``requires_dm_pairing`` / ``requires_outbound_delivery``
新增渠道优先用 ``CUSTOM`` + manifest 声明避免修改框架契约层枚举
- 框架层contract / core / application**禁止** 在任何决策语句中
分支到具体 channel_type 渠道特性决策 **必须** 通过
``ChannelManifest`` 声明字段驱动
- 新增渠道仅需在插件 manifest 中声明 channel_type 字符串无需
修改本类
- channel_type 值在系统中全局唯一 ``PluginRegistry.register``
校验两个插件不得共用同一 channel_type
"""
FEISHU = "feishu"
DINGTALK = "dingtalk"
WECOM = "wecom"
WEBCHAT = "webchat"
TELEGRAM = "telegram"
DISCORD = "discord"
WHATSAPP = "whatsapp"
CUSTOM = "custom"
__slots__ = ()
@classmethod
def __get_pydantic_core_schema__(cls, source_type: Any, handler: Any) -> Any:
"""Pydantic 核心 schema以 ``str`` 校验后包装为 ``ChannelType``。
``ChannelType`` ``str`` 子类 ``StrEnum``Pydantic 默认
无法为其生成 schema导致 ``ChannelType | None`` 作为 FastAPI
``Query`` / ``Field`` 参数或 Pydantic 模型字段时抛
``PydanticSchemaGenerationError``本方法以 ``str_schema`` 校验
输入后用 ``cls`` 包装保留 str 子类的开放值语义支持
``ChannelType("wechat_ilink")`` 等动态值无需预定义枚举成员
``pydantic_core`` 在方法内延迟导入避免在 contract 层引入硬依赖
保持 DTO 模块"仅依赖标准库"的约束 Pydantic 环境下本类仍可正常
使用仅失去 Pydantic 字段类型支持
"""
from pydantic_core import core_schema
return core_schema.no_info_after_validator_function(
cls,
core_schema.str_schema(),
)
class AccountStatus(StrEnum):

View File

@ -198,8 +198,7 @@ class MessageContent:
if fmt not in valid_formats:
raise ValidationError(
"format",
f"unsupported format: {fmt}, must be one of: "
f"{', '.join(f.value for f in MessageFormat)}",
f"unsupported format: {fmt}, must be one of: {', '.join(f.value for f in MessageFormat)}",
)
raw_attachments = data.get("attachments", [])
attachments = tuple(Attachment(**a) if isinstance(a, dict) else a for a in raw_attachments)

View File

@ -331,7 +331,7 @@ class OutboxQueryFilter:
"""
result: dict[str, Any] = {}
if self.channel_type is not None:
result["channel_type"] = self.channel_type.value
result["channel_type"] = self.channel_type
if self.channel_account_id is not None:
result["channel_account_id"] = self.channel_account_id
if self.status is not None:

View File

@ -166,7 +166,7 @@ class PluginSummary:
plugin_id: 插件唯一标识manifest.manifest.id
name: 插件显示名称manifest.manifest.name
version: 插件版本manifest.manifest.version
channel_type: 绑定渠道类型manifest.manifest.channel_type.value
channel_type: 绑定渠道类型manifest.manifest.channel_type
state: 当前生命周期状态LifecycleState.value
"""

View File

@ -202,9 +202,9 @@ class UnsetType:
哨兵实例 ``UNSET`` 表示"不修改该字段,保留原值"
"""
_instance: "UnsetType | None" = None
_instance: UnsetType | None = None
def __new__(cls) -> "UnsetType":
def __new__(cls) -> UnsetType:
if cls._instance is None:
cls._instance = super().__new__(cls)
return cls._instance

View File

@ -21,11 +21,8 @@ class ClientError(Error):
error_code = "CLIENT_ERROR"
class ValidationError(ClientError, ValueError):
"""字段校验失败错误。
同时继承 ValueError 以兼容 Pydantic field_validator
"""
class ValidationError(ClientError):
"""字段校验失败错误。"""
error_code = "VALIDATION_ERROR"

View File

@ -63,9 +63,7 @@ class TransportError(Error):
# error_code 设为实例属性以覆盖基类类变量,按 category 动态决定。
# status_code 由基类 property 经 CHANNEL_ERROR_STATUS_MAP 查表派生,
# 无需也无法在此赋值(基类 status_code 为只读 property无 setter
self.error_code = _TRANSPORT_ERROR_CODE.get(
category, _TRANSPORT_DEFAULT_ERROR_CODE
)
self.error_code = _TRANSPORT_ERROR_CODE.get(category, _TRANSPORT_DEFAULT_ERROR_CODE)
def to_dict(self) -> dict[str, Any]:
result = super().to_dict()

View File

@ -17,7 +17,6 @@ from yuxi.channels.contract.dtos.channel import ChannelAccount
from yuxi.channels.contract.dtos.media import AttachmentUploadResult
from yuxi.channels.contract.errors import NotImplementedError
__all__ = ["AttachmentUploadAdapter"]

View File

@ -12,7 +12,6 @@ from yuxi.channels.contract.dtos.command import ChannelCommand, CommandResult
from yuxi.channels.contract.dtos.common import MessageContent
from yuxi.channels.contract.errors import NotImplementedError
__all__ = ["CommandAdapter"]

View File

@ -19,7 +19,6 @@ from yuxi.channels.contract.dtos.content_review import (
)
from yuxi.channels.contract.errors import NotImplementedError
__all__ = ["ContentModerationAdapter"]

View File

@ -17,7 +17,6 @@ from yuxi.channels.contract.dtos.directory import (
)
from yuxi.channels.contract.errors import NotImplementedError
__all__ = ["DirectoryAdapter"]

View File

@ -17,7 +17,6 @@ from yuxi.channels.contract.dtos.doctor import (
)
from yuxi.channels.contract.errors import NotImplementedError
__all__ = ["DoctorAdapter"]

View File

@ -15,7 +15,6 @@ from yuxi.channels.contract.dtos.identity import (
from yuxi.channels.contract.errors import NotImplementedError
from yuxi.channels.contract.plugin.capability import CapabilityProver
__all__ = ["IdentityResolverAdapter"]

View File

@ -14,7 +14,6 @@ from yuxi.channels.contract.dtos.common import Attachment, MessageContent, RawEv
from yuxi.channels.contract.dtos.inbound import SignatureVerifyResult
from yuxi.channels.contract.errors import NotImplementedError
__all__ = ["InboundAdapter"]

View File

@ -15,7 +15,6 @@ from yuxi.channels.contract.dtos.channel import (
)
from yuxi.channels.contract.errors import NotImplementedError
__all__ = ["LifecycleAdapter"]

View File

@ -17,7 +17,6 @@ from yuxi.channels.contract.dtos.login import (
)
from yuxi.channels.contract.errors import NotImplementedError
__all__ = ["LoginAdapter"]

View File

@ -12,7 +12,6 @@ from yuxi.channels.contract.dtos.common import RawEvent
from yuxi.channels.contract.dtos.mention import MentionContext, MentionTarget
from yuxi.channels.contract.errors import NotImplementedError
__all__ = ["MentionAdapter"]

View File

@ -20,7 +20,6 @@ from yuxi.channels.contract.dtos.outbound import FormattedMessage
from yuxi.channels.contract.dtos.trusted import MessageOperationContext
from yuxi.channels.contract.errors import NotImplementedError
__all__ = ["MessageOpsAdapter"]

View File

@ -17,7 +17,6 @@ from yuxi.channels.contract.dtos.outbound import (
from yuxi.channels.contract.dtos.outbox import MultiPartReceipt
from yuxi.channels.contract.errors import NotImplementedError
__all__ = ["OutboundAdapter"]

View File

@ -15,7 +15,6 @@ from typing import Protocol, runtime_checkable
from yuxi.channels.contract.dtos.health import AdapterProbeOutcome, ProbeResult
from yuxi.channels.contract.errors import NotImplementedError
__all__ = ["ProbeableAdapter"]

View File

@ -11,7 +11,6 @@ from typing import Protocol, runtime_checkable
from yuxi.channels.contract.dtos.transport import PollingConfig, PollResult
from yuxi.channels.contract.errors import NotImplementedError
__all__ = ["PullerAdapter"]

View File

@ -11,7 +11,6 @@ from typing import Any, Protocol, runtime_checkable
from yuxi.channels.contract.dtos.outbound import RichMessage
from yuxi.channels.contract.errors import NotImplementedError
__all__ = ["RichMessageAdapter"]

View File

@ -12,7 +12,6 @@ from yuxi.channels.contract.dtos.channel import SessionInfo
from yuxi.channels.contract.dtos.common import RawEvent
from yuxi.channels.contract.errors import NotImplementedError
__all__ = ["SessionAdapter"]

View File

@ -12,7 +12,6 @@ from yuxi.channels.contract.dtos.common import RawEvent
from yuxi.channels.contract.dtos.status import EventType, StatusPayload
from yuxi.channels.contract.errors import NotImplementedError
__all__ = ["StatusAdapter"]

Some files were not shown because too many files have changed in this diff Show More