diff --git a/backend/package/yuxi/channels/adapters/__init__.py b/backend/package/yuxi/channels/adapters/__init__.py index cbee9a0d..3beb0f09 100644 --- a/backend/package/yuxi/channels/adapters/__init__.py +++ b/backend/package/yuxi/channels/adapters/__init__.py @@ -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 自动脱敏; diff --git a/backend/package/yuxi/channels/adapters/agent_run_adapter.py b/backend/package/yuxi/channels/adapters/agent_run_adapter.py index 273fe7ce..f1a8f145 100644 --- a/backend/package/yuxi/channels/adapters/agent_run_adapter.py +++ b/backend/package/yuxi/channels/adapters/agent_run_adapter.py @@ -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 diff --git a/backend/package/yuxi/channels/adapters/channel_persistence_adapter.py b/backend/package/yuxi/channels/adapters/channel_persistence_adapter.py index 69013a59..d5de007b 100644 --- a/backend/package/yuxi/channels/adapters/channel_persistence_adapter.py +++ b/backend/package/yuxi/channels/adapters/channel_persistence_adapter.py @@ -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, diff --git a/backend/package/yuxi/channels/adapters/content_review_repository_adapter.py b/backend/package/yuxi/channels/adapters/content_review_repository_adapter.py index 96f07832..97f8d27e 100644 --- a/backend/package/yuxi/channels/adapters/content_review_repository_adapter.py +++ b/backend/package/yuxi/channels/adapters/content_review_repository_adapter.py @@ -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), diff --git a/backend/package/yuxi/channels/adapters/conversation_adapter.py b/backend/package/yuxi/channels/adapters/conversation_adapter.py index e19e2f6f..bcfc9f63 100644 --- a/backend/package/yuxi/channels/adapters/conversation_adapter.py +++ b/backend/package/yuxi/channels/adapters/conversation_adapter.py @@ -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: diff --git a/backend/package/yuxi/channels/adapters/mappers.py b/backend/package/yuxi/channels/adapters/mappers.py index b50c86d5..b539f7cb 100644 --- a/backend/package/yuxi/channels/adapters/mappers.py +++ b/backend/package/yuxi/channels/adapters/mappers.py @@ -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, diff --git a/backend/package/yuxi/channels/adapters/message_ops_port_adapter.py b/backend/package/yuxi/channels/adapters/message_ops_port_adapter.py index 63dd8bb1..36ad659f 100644 --- a/backend/package/yuxi/channels/adapters/message_ops_port_adapter.py +++ b/backend/package/yuxi/channels/adapters/message_ops_port_adapter.py @@ -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) diff --git a/backend/package/yuxi/channels/adapters/service_account_adapter.py b/backend/package/yuxi/channels/adapters/service_account_adapter.py index 8875ec23..5b94ee3c 100644 --- a/backend/package/yuxi/channels/adapters/service_account_adapter.py +++ b/backend/package/yuxi/channels/adapters/service_account_adapter.py @@ -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", diff --git a/backend/package/yuxi/channels/application/circuit_breaker/channel_circuit_breaker.py b/backend/package/yuxi/channels/application/circuit_breaker/channel_circuit_breaker.py index 4b5bfdcf..00ed5cee 100644 --- a/backend/package/yuxi/channels/application/circuit_breaker/channel_circuit_breaker.py +++ b/backend/package/yuxi/channels/application/circuit_breaker/channel_circuit_breaker.py @@ -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, ) diff --git a/backend/package/yuxi/channels/application/executor/channel_tool_executor.py b/backend/package/yuxi/channels/application/executor/channel_tool_executor.py index 6d5da8ab..a035be7a 100644 --- a/backend/package/yuxi/channels/application/executor/channel_tool_executor.py +++ b/backend/package/yuxi/channels/application/executor/channel_tool_executor.py @@ -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 diff --git a/backend/package/yuxi/channels/application/executor/message_operation_executor.py b/backend/package/yuxi/channels/application/executor/message_operation_executor.py index 959fb890..90c7b6f7 100644 --- a/backend/package/yuxi/channels/application/executor/message_operation_executor.py +++ b/backend/package/yuxi/channels/application/executor/message_operation_executor.py @@ -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 diff --git a/backend/package/yuxi/channels/application/extension/handlers/plugin_lifecycle_audit_handler.py b/backend/package/yuxi/channels/application/extension/handlers/plugin_lifecycle_audit_handler.py index cb6664a6..f9b899ad 100644 --- a/backend/package/yuxi/channels/application/extension/handlers/plugin_lifecycle_audit_handler.py +++ b/backend/package/yuxi/channels/application/extension/handlers/plugin_lifecycle_audit_handler.py @@ -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) diff --git a/backend/package/yuxi/channels/application/health/channel_probe.py b/backend/package/yuxi/channels/application/health/channel_probe.py index a15ed9bd..b16a8498 100644 --- a/backend/package/yuxi/channels/application/health/channel_probe.py +++ b/backend/package/yuxi/channels/application/health/channel_probe.py @@ -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), ) diff --git a/backend/package/yuxi/channels/application/health/diagnostics_exporter.py b/backend/package/yuxi/channels/application/health/diagnostics_exporter.py index 3aa2181e..5060ad61 100644 --- a/backend/package/yuxi/channels/application/health/diagnostics_exporter.py +++ b/backend/package/yuxi/channels/application/health/diagnostics_exporter.py @@ -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, diff --git a/backend/package/yuxi/channels/application/health/health_aggregator.py b/backend/package/yuxi/channels/application/health/health_aggregator.py index 207f332a..feafcb11 100644 --- a/backend/package/yuxi/channels/application/health/health_aggregator.py +++ b/backend/package/yuxi/channels/application/health/health_aggregator.py @@ -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", ""), diff --git a/backend/package/yuxi/channels/application/lifecycle/plugin_lifecycle_manager.py b/backend/package/yuxi/channels/application/lifecycle/plugin_lifecycle_manager.py index e3c4609c..5319de02 100644 --- a/backend/package/yuxi/channels/application/lifecycle/plugin_lifecycle_manager.py +++ b/backend/package/yuxi/channels/application/lifecycle/plugin_lifecycle_manager.py @@ -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( diff --git a/backend/package/yuxi/channels/application/lifecycle/plugin_loader.py b/backend/package/yuxi/channels/application/lifecycle/plugin_loader.py index f83da6e9..704aacdf 100644 --- a/backend/package/yuxi/channels/application/lifecycle/plugin_loader.py +++ b/backend/package/yuxi/channels/application/lifecycle/plugin_loader.py @@ -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"]) diff --git a/backend/package/yuxi/channels/application/outbox/outbox_retry_worker.py b/backend/package/yuxi/channels/application/outbox/outbox_retry_worker.py index ab4c763a..5adf713f 100644 --- a/backend/package/yuxi/channels/application/outbox/outbox_retry_worker.py +++ b/backend/package/yuxi/channels/application/outbox/outbox_retry_worker.py @@ -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, ) diff --git a/backend/package/yuxi/channels/application/pipeline/base.py b/backend/package/yuxi/channels/application/pipeline/base.py index 3b23784a..56af9e79 100644 --- a/backend/package/yuxi/channels/application/pipeline/base.py +++ b/backend/package/yuxi/channels/application/pipeline/base.py @@ -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: diff --git a/backend/package/yuxi/channels/application/pipeline/control_plane/audit_context_builder.py b/backend/package/yuxi/channels/application/pipeline/control_plane/audit_context_builder.py index 8acb9d89..56028ccc 100644 --- a/backend/package/yuxi/channels/application/pipeline/control_plane/audit_context_builder.py +++ b/backend/package/yuxi/channels/application/pipeline/control_plane/audit_context_builder.py @@ -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: diff --git a/backend/package/yuxi/channels/application/pipeline/control_plane/control_plane_pipeline.py b/backend/package/yuxi/channels/application/pipeline/control_plane/control_plane_pipeline.py index 2510092a..cd018920 100644 --- a/backend/package/yuxi/channels/application/pipeline/control_plane/control_plane_pipeline.py +++ b/backend/package/yuxi/channels/application/pipeline/control_plane/control_plane_pipeline.py @@ -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, - # 其余批量 handler(config/account/message/session/content_review/ - # pairing/whitelist/outbox)内部按 transaction_port 自建。 - batch_executor = BatchExecutor(transaction_port) + # 所有批量 handler(config/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( diff --git a/backend/package/yuxi/channels/application/pipeline/control_plane/handlers/account_handler.py b/backend/package/yuxi/channels/application/pipeline/control_plane/handlers/account_handler.py index bee24400..f23bea43 100644 --- a/backend/package/yuxi/channels/application/pipeline/control_plane/handlers/account_handler.py +++ b/backend/package/yuxi/channels/application/pipeline/control_plane/handlers/account_handler.py @@ -237,6 +237,16 @@ class AccountHandler(ControlPlaneHandler): raw_config = _requireParam(ctx, "raw_config") display_name = _requireParam(ctx, "display_name") + # 前置校验:channel_type 必须已注册生命周期适配器。未注册时 + # lifecycle service 的 _requireAdapter 会抛 NotImplementedError(501), + # 此处提前翻译为 RuleViolationError(422),明确告知客户端该渠道 + # 类型不支持账户创建。 + 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", diff --git a/backend/package/yuxi/channels/application/pipeline/control_plane/handlers/agent_handler.py b/backend/package/yuxi/channels/application/pipeline/control_plane/handlers/agent_handler.py index 80197cd1..acd828dd 100644 --- a/backend/package/yuxi/channels/application/pipeline/control_plane/handlers/agent_handler.py +++ b/backend/package/yuxi/channels/application/pipeline/control_plane/handlers/agent_handler.py @@ -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): diff --git a/backend/package/yuxi/channels/application/pipeline/control_plane/handlers/capability_handler.py b/backend/package/yuxi/channels/application/pipeline/control_plane/handlers/capability_handler.py index a50e386a..8fed2ee9 100644 --- a/backend/package/yuxi/channels/application/pipeline/control_plane/handlers/capability_handler.py +++ b/backend/package/yuxi/channels/application/pipeline/control_plane/handlers/capability_handler.py @@ -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, } diff --git a/backend/package/yuxi/channels/application/pipeline/control_plane/handlers/config_handler.py b/backend/package/yuxi/channels/application/pipeline/control_plane/handlers/config_handler.py index 9354d447..c70c117a 100644 --- a/backend/package/yuxi/channels/application/pipeline/control_plane/handlers/config_handler.py +++ b/backend/package/yuxi/channels/application/pipeline/control_plane/handlers/config_handler.py @@ -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": [ diff --git a/backend/package/yuxi/channels/application/pipeline/control_plane/handlers/dashboard_handler.py b/backend/package/yuxi/channels/application/pipeline/control_plane/handlers/dashboard_handler.py index f155f993..89c29f91 100644 --- a/backend/package/yuxi/channels/application/pipeline/control_plane/handlers/dashboard_handler.py +++ b/backend/package/yuxi/channels/application/pipeline/control_plane/handlers/dashboard_handler.py @@ -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( diff --git a/backend/package/yuxi/channels/application/pipeline/control_plane/handlers/directory_handler.py b/backend/package/yuxi/channels/application/pipeline/control_plane/handlers/directory_handler.py index e18b24a2..4e551b71 100644 --- a/backend/package/yuxi/channels/application/pipeline/control_plane/handlers/directory_handler.py +++ b/backend/package/yuxi/channels/application/pipeline/control_plane/handlers/directory_handler.py @@ -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}:*" diff --git a/backend/package/yuxi/channels/application/pipeline/control_plane/handlers/doctor_handler.py b/backend/package/yuxi/channels/application/pipeline/control_plane/handlers/doctor_handler.py index 7517061b..fabf001b 100644 --- a/backend/package/yuxi/channels/application/pipeline/control_plane/handlers/doctor_handler.py +++ b/backend/package/yuxi/channels/application/pipeline/control_plane/handlers/doctor_handler.py @@ -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]: diff --git a/backend/package/yuxi/channels/application/pipeline/control_plane/handlers/health_handler.py b/backend/package/yuxi/channels/application/pipeline/control_plane/handlers/health_handler.py index c0b0d726..6b6a810b 100644 --- a/backend/package/yuxi/channels/application/pipeline/control_plane/handlers/health_handler.py +++ b/backend/package/yuxi/channels/application/pipeline/control_plane/handlers/health_handler.py @@ -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, diff --git a/backend/package/yuxi/channels/application/pipeline/control_plane/handlers/login_handler.py b/backend/package/yuxi/channels/application/pipeline/control_plane/handlers/login_handler.py index 4bcb27bb..d8046538 100644 --- a/backend/package/yuxi/channels/application/pipeline/control_plane/handlers/login_handler.py +++ b/backend/package/yuxi/channels/application/pipeline/control_plane/handlers/login_handler.py @@ -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} diff --git a/backend/package/yuxi/channels/application/pipeline/control_plane/handlers/message_handler.py b/backend/package/yuxi/channels/application/pipeline/control_plane/handlers/message_handler.py index 02d2d22e..82af9de6 100644 --- a/backend/package/yuxi/channels/application/pipeline/control_plane/handlers/message_handler.py +++ b/backend/package/yuxi/channels/application/pipeline/control_plane/handlers/message_handler.py @@ -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, ) diff --git a/backend/package/yuxi/channels/application/pipeline/control_plane/handlers/outbox_handler.py b/backend/package/yuxi/channels/application/pipeline/control_plane/handlers/outbox_handler.py index 1c186cb3..9a924151 100644 --- a/backend/package/yuxi/channels/application/pipeline/control_plane/handlers/outbox_handler.py +++ b/backend/package/yuxi/channels/application/pipeline/control_plane/handlers/outbox_handler.py @@ -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) diff --git a/backend/package/yuxi/channels/application/pipeline/control_plane/handlers/plugin_handler.py b/backend/package/yuxi/channels/application/pipeline/control_plane/handlers/plugin_handler.py index 0f03d926..f0887bad 100644 --- a/backend/package/yuxi/channels/application/pipeline/control_plane/handlers/plugin_handler.py +++ b/backend/package/yuxi/channels/application/pipeline/control_plane/handlers/plugin_handler.py @@ -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) diff --git a/backend/package/yuxi/channels/application/pipeline/control_plane/handlers/reports_handler.py b/backend/package/yuxi/channels/application/pipeline/control_plane/handlers/reports_handler.py index e589992c..ab20d5a9 100644 --- a/backend/package/yuxi/channels/application/pipeline/control_plane/handlers/reports_handler.py +++ b/backend/package/yuxi/channels/application/pipeline/control_plane/handlers/reports_handler.py @@ -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, diff --git a/backend/package/yuxi/channels/application/pipeline/control_plane/handlers/session_handler.py b/backend/package/yuxi/channels/application/pipeline/control_plane/handlers/session_handler.py index 852c266b..ef581d69 100644 --- a/backend/package/yuxi/channels/application/pipeline/control_plane/handlers/session_handler.py +++ b/backend/package/yuxi/channels/application/pipeline/control_plane/handlers/session_handler.py @@ -123,7 +123,20 @@ class SessionHandler(ControlPlaneHandler): 策略门:读取 ``merge_strategy_enabled`` 配置,falsy 时抛 ``RuleViolationError``。 + + 检查顺序:先校验目标会话存在(404),再检查策略门(422), + 确保不存在的会话返回 404 而非 422。 """ + # 必填参数经 _requireParam 校验,缺失时抛 ValidationError(400) + target_conversation_id = _requireParam(ctx, "target_conversation_id") + # 目标会话存在性检查:路径 session_id 映射为 target_conversation_id, + # 通过 conversation_id 反查渠道会话,不存在时抛 NotFoundError(404)。 + # 此检查在策略门之前,确保非存在会话返回 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 校验,缺失时抛 ValidationError(400) 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, } diff --git a/backend/package/yuxi/channels/application/pipeline/control_plane/handlers/webhook_handler.py b/backend/package/yuxi/channels/application/pipeline/control_plane/handlers/webhook_handler.py index 963e1d33..0dc3a2e0 100644 --- a/backend/package/yuxi/channels/application/pipeline/control_plane/handlers/webhook_handler.py +++ b/backend/package/yuxi/channels/application/pipeline/control_plane/handlers/webhook_handler.py @@ -86,7 +86,7 @@ class WebhookHandler(ControlPlaneHandler): if not accounts: raise NotFoundError( resource="channel_account", - id=f"{channel_type.value}:", + id=f"{channel_type}:", trace_id=ctx.trace_id, ) account_dto = accounts[0] diff --git a/backend/package/yuxi/channels/application/pipeline/control_plane/handlers/whitelist_handler.py b/backend/package/yuxi/channels/application/pipeline/control_plane/handlers/whitelist_handler.py index adab84f8..513c20ab 100644 --- a/backend/package/yuxi/channels/application/pipeline/control_plane/handlers/whitelist_handler.py +++ b/backend/package/yuxi/channels/application/pipeline/control_plane/handlers/whitelist_handler.py @@ -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) diff --git a/backend/package/yuxi/channels/application/pipeline/control_plane/operation_audit_meta.py b/backend/package/yuxi/channels/application/pipeline/control_plane/operation_audit_meta.py index 820b49f7..5627b89c 100644 --- a/backend/package/yuxi/channels/application/pipeline/control_plane/operation_audit_meta.py +++ b/backend/package/yuxi/channels/application/pipeline/control_plane/operation_audit_meta.py @@ -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)。 diff --git a/backend/package/yuxi/channels/application/pipeline/control_plane/serializers.py b/backend/package/yuxi/channels/application/pipeline/control_plane/serializers.py index 394e0abf..82742856 100644 --- a/backend/package/yuxi/channels/application/pipeline/control_plane/serializers.py +++ b/backend/package/yuxi/channels/application/pipeline/control_plane/serializers.py @@ -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, diff --git a/backend/package/yuxi/channels/application/pipeline/inbound/agent_run_enqueue_stage.py b/backend/package/yuxi/channels/application/pipeline/inbound/agent_run_enqueue_stage.py index 0148cca9..84208c6b 100644 --- a/backend/package/yuxi/channels/application/pipeline/inbound/agent_run_enqueue_stage.py +++ b/backend/package/yuxi/channels/application/pipeline/inbound/agent_run_enqueue_stage.py @@ -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 diff --git a/backend/package/yuxi/channels/application/pipeline/inbound/classify_stage.py b/backend/package/yuxi/channels/application/pipeline/inbound/classify_stage.py index 5548f823..5930e3bf 100644 --- a/backend/package/yuxi/channels/application/pipeline/inbound/classify_stage.py +++ b/backend/package/yuxi/channels/application/pipeline/inbound/classify_stage.py @@ -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) diff --git a/backend/package/yuxi/channels/application/pipeline/inbound/media_fetch_stage.py b/backend/package/yuxi/channels/application/pipeline/inbound/media_fetch_stage.py index 4a87ba3e..987bd404 100644 --- a/backend/package/yuxi/channels/application/pipeline/inbound/media_fetch_stage.py +++ b/backend/package/yuxi/channels/application/pipeline/inbound/media_fetch_stage.py @@ -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 diff --git a/backend/package/yuxi/channels/application/pipeline/inbound/receive_stage.py b/backend/package/yuxi/channels/application/pipeline/inbound/receive_stage.py index 7b7b2a52..5c2fa740 100644 --- a/backend/package/yuxi/channels/application/pipeline/inbound/receive_stage.py +++ b/backend/package/yuxi/channels/application/pipeline/inbound/receive_stage.py @@ -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 diff --git a/backend/package/yuxi/channels/application/pipeline/inbound/reply_stage.py b/backend/package/yuxi/channels/application/pipeline/inbound/reply_stage.py index 8fb01ace..dc3cc1b5 100644 --- a/backend/package/yuxi/channels/application/pipeline/inbound/reply_stage.py +++ b/backend/package/yuxi/channels/application/pipeline/inbound/reply_stage.py @@ -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 策略。 diff --git a/backend/package/yuxi/channels/application/pipeline/inbound/route_stage.py b/backend/package/yuxi/channels/application/pipeline/inbound/route_stage.py index ed095b3b..a7bbcc46 100644 --- a/backend/package/yuxi/channels/application/pipeline/inbound/route_stage.py +++ b/backend/package/yuxi/channels/application/pipeline/inbound/route_stage.py @@ -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, diff --git a/backend/package/yuxi/channels/application/pipeline/inbound/security_stage.py b/backend/package/yuxi/channels/application/pipeline/inbound/security_stage.py index 9c781709..fe10ab4d 100644 --- a/backend/package/yuxi/channels/application/pipeline/inbound/security_stage.py +++ b/backend/package/yuxi/channels/application/pipeline/inbound/security_stage.py @@ -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) diff --git a/backend/package/yuxi/channels/application/pipeline/inbound/service_account_resolve_stage.py b/backend/package/yuxi/channels/application/pipeline/inbound/service_account_resolve_stage.py index fd445686..186a454e 100644 --- a/backend/package/yuxi/channels/application/pipeline/inbound/service_account_resolve_stage.py +++ b/backend/package/yuxi/channels/application/pipeline/inbound/service_account_resolve_stage.py @@ -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, ) diff --git a/backend/package/yuxi/channels/application/pipeline/inbound/session_resolve_stage.py b/backend/package/yuxi/channels/application/pipeline/inbound/session_resolve_stage.py index ac8ed16c..563af75f 100644 --- a/backend/package/yuxi/channels/application/pipeline/inbound/session_resolve_stage.py +++ b/backend/package/yuxi/channels/application/pipeline/inbound/session_resolve_stage.py @@ -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 diff --git a/backend/package/yuxi/channels/application/pipeline/inbound/signature_verify_stage.py b/backend/package/yuxi/channels/application/pipeline/inbound/signature_verify_stage.py index f4112f4e..253657c3 100644 --- a/backend/package/yuxi/channels/application/pipeline/inbound/signature_verify_stage.py +++ b/backend/package/yuxi/channels/application/pipeline/inbound/signature_verify_stage.py @@ -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 diff --git a/backend/package/yuxi/channels/application/pipeline/outbound/capability_verify_stage.py b/backend/package/yuxi/channels/application/pipeline/outbound/capability_verify_stage.py index ab0bf836..6009ea41 100644 --- a/backend/package/yuxi/channels/application/pipeline/outbound/capability_verify_stage.py +++ b/backend/package/yuxi/channels/application/pipeline/outbound/capability_verify_stage.py @@ -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"] diff --git a/backend/package/yuxi/channels/application/pipeline/outbound/deliver_stage.py b/backend/package/yuxi/channels/application/pipeline/outbound/deliver_stage.py index 8b2384e5..007e198f 100644 --- a/backend/package/yuxi/channels/application/pipeline/outbound/deliver_stage.py +++ b/backend/package/yuxi/channels/application/pipeline/outbound/deliver_stage.py @@ -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 diff --git a/backend/package/yuxi/channels/application/pipeline/outbound/fence_check_stage.py b/backend/package/yuxi/channels/application/pipeline/outbound/fence_check_stage.py index 9d828840..74fec290 100644 --- a/backend/package/yuxi/channels/application/pipeline/outbound/fence_check_stage.py +++ b/backend/package/yuxi/channels/application/pipeline/outbound/fence_check_stage.py @@ -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 diff --git a/backend/package/yuxi/channels/application/pipeline/outbound/format_stage.py b/backend/package/yuxi/channels/application/pipeline/outbound/format_stage.py index 731bcb2f..5481cba7 100644 --- a/backend/package/yuxi/channels/application/pipeline/outbound/format_stage.py +++ b/backend/package/yuxi/channels/application/pipeline/outbound/format_stage.py @@ -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 diff --git a/backend/package/yuxi/channels/application/pipeline/outbound/load_build_stage.py b/backend/package/yuxi/channels/application/pipeline/outbound/load_build_stage.py index 64e94dfd..eb54e075 100644 --- a/backend/package/yuxi/channels/application/pipeline/outbound/load_build_stage.py +++ b/backend/package/yuxi/channels/application/pipeline/outbound/load_build_stage.py @@ -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"] diff --git a/backend/package/yuxi/channels/application/pipeline/outbound/outbound_pipeline.py b/backend/package/yuxi/channels/application/pipeline/outbound/outbound_pipeline.py index d41737fb..7f709789 100644 --- a/backend/package/yuxi/channels/application/pipeline/outbound/outbound_pipeline.py +++ b/backend/package/yuxi/channels/application/pipeline/outbound/outbound_pipeline.py @@ -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"] diff --git a/backend/package/yuxi/channels/application/pipeline/outbound/outbox_mark_failed_stage.py b/backend/package/yuxi/channels/application/pipeline/outbound/outbox_mark_failed_stage.py index ec5bc9eb..1e13e701 100644 --- a/backend/package/yuxi/channels/application/pipeline/outbound/outbox_mark_failed_stage.py +++ b/backend/package/yuxi/channels/application/pipeline/outbound/outbox_mark_failed_stage.py @@ -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"] diff --git a/backend/package/yuxi/channels/application/pipeline/outbound/outbox_persist_stage.py b/backend/package/yuxi/channels/application/pipeline/outbound/outbox_persist_stage.py index 671cb36f..df263dea 100644 --- a/backend/package/yuxi/channels/application/pipeline/outbound/outbox_persist_stage.py +++ b/backend/package/yuxi/channels/application/pipeline/outbound/outbox_persist_stage.py @@ -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"] diff --git a/backend/package/yuxi/channels/application/pipeline/outbound/outbox_rollback_stage.py b/backend/package/yuxi/channels/application/pipeline/outbound/outbox_rollback_stage.py index e435b0fe..97c463b2 100644 --- a/backend/package/yuxi/channels/application/pipeline/outbound/outbox_rollback_stage.py +++ b/backend/package/yuxi/channels/application/pipeline/outbound/outbox_rollback_stage.py @@ -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"] diff --git a/backend/package/yuxi/channels/application/pipeline/outbound/prefix_stage.py b/backend/package/yuxi/channels/application/pipeline/outbound/prefix_stage.py index 27a14f5f..cf81705b 100644 --- a/backend/package/yuxi/channels/application/pipeline/outbound/prefix_stage.py +++ b/backend/package/yuxi/channels/application/pipeline/outbound/prefix_stage.py @@ -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"] diff --git a/backend/package/yuxi/channels/application/pipeline/outbound/status_writeback_stage.py b/backend/package/yuxi/channels/application/pipeline/outbound/status_writeback_stage.py index 77a0903a..440c2391 100644 --- a/backend/package/yuxi/channels/application/pipeline/outbound/status_writeback_stage.py +++ b/backend/package/yuxi/channels/application/pipeline/outbound/status_writeback_stage.py @@ -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"] diff --git a/backend/package/yuxi/channels/application/pipeline/outbound/stream_chunk_stage.py b/backend/package/yuxi/channels/application/pipeline/outbound/stream_chunk_stage.py index 973838be..e639c6e7 100644 --- a/backend/package/yuxi/channels/application/pipeline/outbound/stream_chunk_stage.py +++ b/backend/package/yuxi/channels/application/pipeline/outbound/stream_chunk_stage.py @@ -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 diff --git a/backend/package/yuxi/channels/application/pipeline/outbound/truncation_check_stage.py b/backend/package/yuxi/channels/application/pipeline/outbound/truncation_check_stage.py index ea40a981..56b89f69 100644 --- a/backend/package/yuxi/channels/application/pipeline/outbound/truncation_check_stage.py +++ b/backend/package/yuxi/channels/application/pipeline/outbound/truncation_check_stage.py @@ -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 diff --git a/backend/package/yuxi/channels/application/pipeline/outbound/trusted_inject_stage.py b/backend/package/yuxi/channels/application/pipeline/outbound/trusted_inject_stage.py index a0dd190f..c0d24be7 100644 --- a/backend/package/yuxi/channels/application/pipeline/outbound/trusted_inject_stage.py +++ b/backend/package/yuxi/channels/application/pipeline/outbound/trusted_inject_stage.py @@ -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"] diff --git a/backend/package/yuxi/channels/application/pipeline/outbound/typing_indicator_stage.py b/backend/package/yuxi/channels/application/pipeline/outbound/typing_indicator_stage.py index ff2bf400..6355e5e5 100644 --- a/backend/package/yuxi/channels/application/pipeline/outbound/typing_indicator_stage.py +++ b/backend/package/yuxi/channels/application/pipeline/outbound/typing_indicator_stage.py @@ -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 diff --git a/backend/package/yuxi/channels/application/pipeline/outbound/typing_stop_stage.py b/backend/package/yuxi/channels/application/pipeline/outbound/typing_stop_stage.py index c8ed3c5b..1bfd7cd4 100644 --- a/backend/package/yuxi/channels/application/pipeline/outbound/typing_stop_stage.py +++ b/backend/package/yuxi/channels/application/pipeline/outbound/typing_stop_stage.py @@ -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 diff --git a/backend/package/yuxi/channels/application/pipeline/outbound/whitelist_check_stage.py b/backend/package/yuxi/channels/application/pipeline/outbound/whitelist_check_stage.py index dac342ec..123a2642 100644 --- a/backend/package/yuxi/channels/application/pipeline/outbound/whitelist_check_stage.py +++ b/backend/package/yuxi/channels/application/pipeline/outbound/whitelist_check_stage.py @@ -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"] diff --git a/backend/package/yuxi/channels/application/transport/base_worker.py b/backend/package/yuxi/channels/application/transport/base_worker.py index 656e925d..43593428 100644 --- a/backend/package/yuxi/channels/application/transport/base_worker.py +++ b/backend/package/yuxi/channels/application/transport/base_worker.py @@ -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, diff --git a/backend/package/yuxi/channels/application/transport/manager.py b/backend/package/yuxi/channels/application/transport/manager.py index 6e819217..7119fa8b 100644 --- a/backend/package/yuxi/channels/application/transport/manager.py +++ b/backend/package/yuxi/channels/application/transport/manager.py @@ -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, ) diff --git a/backend/package/yuxi/channels/application/transport/puller_worker.py b/backend/package/yuxi/channels/application/transport/puller_worker.py index c880345f..ed0ff0cb 100644 --- a/backend/package/yuxi/channels/application/transport/puller_worker.py +++ b/backend/package/yuxi/channels/application/transport/puller_worker.py @@ -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), diff --git a/backend/package/yuxi/channels/application/transport/stream_worker.py b/backend/package/yuxi/channels/application/transport/stream_worker.py index 0ddee1c2..b03de30c 100644 --- a/backend/package/yuxi/channels/application/transport/stream_worker.py +++ b/backend/package/yuxi/channels/application/transport/stream_worker.py @@ -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), diff --git a/backend/package/yuxi/channels/application/usecase/admin_message_service.py b/backend/package/yuxi/channels/application/usecase/admin_message_service.py index 2c866177..4738e2ef 100644 --- a/backend/package/yuxi/channels/application/usecase/admin_message_service.py +++ b/backend/package/yuxi/channels/application/usecase/admin_message_service.py @@ -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``:仅会话 ID,channel_type 置为 CUSTOM、account_id 置空 + - ``session_id``:仅会话 ID,channel_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: """将管道错误转换为失败详情。 diff --git a/backend/package/yuxi/channels/application/usecase/channel_control_service.py b/backend/package/yuxi/channels/application/usecase/channel_control_service.py index 138cb9ad..aaf57944 100644 --- a/backend/package/yuxi/channels/application/usecase/channel_control_service.py +++ b/backend/package/yuxi/channels/application/usecase/channel_control_service.py @@ -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", diff --git a/backend/package/yuxi/channels/application/usecase/health_check_service.py b/backend/package/yuxi/channels/application/usecase/health_check_service.py index 80d0978b..094ac176 100644 --- a/backend/package/yuxi/channels/application/usecase/health_check_service.py +++ b/backend/package/yuxi/channels/application/usecase/health_check_service.py @@ -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, ) diff --git a/backend/package/yuxi/channels/application/usecase/inbound_message_service.py b/backend/package/yuxi/channels/application/usecase/inbound_message_service.py index 24104c20..657ce9e9 100644 --- a/backend/package/yuxi/channels/application/usecase/inbound_message_service.py +++ b/backend/package/yuxi/channels/application/usecase/inbound_message_service.py @@ -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}:", + f"{cmd.channel_type}:", ) 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 为 None),HTTP 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) diff --git a/backend/package/yuxi/channels/contract/__init__.py b/backend/package/yuxi/channels/contract/__init__.py index 4e34c695..4519b0b8 100644 --- a/backend/package/yuxi/channels/contract/__init__.py +++ b/backend/package/yuxi/channels/contract/__init__.py @@ -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, ) diff --git a/backend/package/yuxi/channels/contract/dtos/__init__.py b/backend/package/yuxi/channels/contract/dtos/__init__.py index f67444b8..e7c6a5f8 100644 --- a/backend/package/yuxi/channels/contract/dtos/__init__.py +++ b/backend/package/yuxi/channels/contract/dtos/__init__.py @@ -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, diff --git a/backend/package/yuxi/channels/contract/dtos/channel.py b/backend/package/yuxi/channels/contract/dtos/channel.py index 4aaed963..c7abc691 100644 --- a/backend/package/yuxi/channels/contract/dtos/channel.py +++ b/backend/package/yuxi/channels/contract/dtos/channel.py @@ -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`` + 子类,可直接作为字典 key、JSON 序列化、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): diff --git a/backend/package/yuxi/channels/contract/dtos/common.py b/backend/package/yuxi/channels/contract/dtos/common.py index 637d38f9..55f56e6b 100644 --- a/backend/package/yuxi/channels/contract/dtos/common.py +++ b/backend/package/yuxi/channels/contract/dtos/common.py @@ -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) diff --git a/backend/package/yuxi/channels/contract/dtos/outbox.py b/backend/package/yuxi/channels/contract/dtos/outbox.py index c5993483..eff31e1f 100644 --- a/backend/package/yuxi/channels/contract/dtos/outbox.py +++ b/backend/package/yuxi/channels/contract/dtos/outbox.py @@ -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: diff --git a/backend/package/yuxi/channels/contract/dtos/plugin.py b/backend/package/yuxi/channels/contract/dtos/plugin.py index 91deb7f2..d96fc0ca 100644 --- a/backend/package/yuxi/channels/contract/dtos/plugin.py +++ b/backend/package/yuxi/channels/contract/dtos/plugin.py @@ -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)。 """ diff --git a/backend/package/yuxi/channels/contract/dtos/whitelist.py b/backend/package/yuxi/channels/contract/dtos/whitelist.py index 65ef515b..13345295 100644 --- a/backend/package/yuxi/channels/contract/dtos/whitelist.py +++ b/backend/package/yuxi/channels/contract/dtos/whitelist.py @@ -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 diff --git a/backend/package/yuxi/channels/contract/errors/client.py b/backend/package/yuxi/channels/contract/errors/client.py index 06137a18..53c0e4d7 100644 --- a/backend/package/yuxi/channels/contract/errors/client.py +++ b/backend/package/yuxi/channels/contract/errors/client.py @@ -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" diff --git a/backend/package/yuxi/channels/contract/errors/transport.py b/backend/package/yuxi/channels/contract/errors/transport.py index 3dab92b0..d694c06b 100644 --- a/backend/package/yuxi/channels/contract/errors/transport.py +++ b/backend/package/yuxi/channels/contract/errors/transport.py @@ -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() diff --git a/backend/package/yuxi/channels/contract/plugin/adapters/attachment_upload_adapter.py b/backend/package/yuxi/channels/contract/plugin/adapters/attachment_upload_adapter.py index be027f89..a7bc601e 100644 --- a/backend/package/yuxi/channels/contract/plugin/adapters/attachment_upload_adapter.py +++ b/backend/package/yuxi/channels/contract/plugin/adapters/attachment_upload_adapter.py @@ -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"] diff --git a/backend/package/yuxi/channels/contract/plugin/adapters/command_adapter.py b/backend/package/yuxi/channels/contract/plugin/adapters/command_adapter.py index 89bff844..a90a71e7 100644 --- a/backend/package/yuxi/channels/contract/plugin/adapters/command_adapter.py +++ b/backend/package/yuxi/channels/contract/plugin/adapters/command_adapter.py @@ -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"] diff --git a/backend/package/yuxi/channels/contract/plugin/adapters/content_moderation_adapter.py b/backend/package/yuxi/channels/contract/plugin/adapters/content_moderation_adapter.py index d7e868b8..e265c9f6 100644 --- a/backend/package/yuxi/channels/contract/plugin/adapters/content_moderation_adapter.py +++ b/backend/package/yuxi/channels/contract/plugin/adapters/content_moderation_adapter.py @@ -19,7 +19,6 @@ from yuxi.channels.contract.dtos.content_review import ( ) from yuxi.channels.contract.errors import NotImplementedError - __all__ = ["ContentModerationAdapter"] diff --git a/backend/package/yuxi/channels/contract/plugin/adapters/directory_adapter.py b/backend/package/yuxi/channels/contract/plugin/adapters/directory_adapter.py index 9e7677f9..f2b7ade9 100644 --- a/backend/package/yuxi/channels/contract/plugin/adapters/directory_adapter.py +++ b/backend/package/yuxi/channels/contract/plugin/adapters/directory_adapter.py @@ -17,7 +17,6 @@ from yuxi.channels.contract.dtos.directory import ( ) from yuxi.channels.contract.errors import NotImplementedError - __all__ = ["DirectoryAdapter"] diff --git a/backend/package/yuxi/channels/contract/plugin/adapters/doctor_adapter.py b/backend/package/yuxi/channels/contract/plugin/adapters/doctor_adapter.py index 835ca6a6..0a4e63eb 100644 --- a/backend/package/yuxi/channels/contract/plugin/adapters/doctor_adapter.py +++ b/backend/package/yuxi/channels/contract/plugin/adapters/doctor_adapter.py @@ -17,7 +17,6 @@ from yuxi.channels.contract.dtos.doctor import ( ) from yuxi.channels.contract.errors import NotImplementedError - __all__ = ["DoctorAdapter"] diff --git a/backend/package/yuxi/channels/contract/plugin/adapters/identity_resolver_adapter.py b/backend/package/yuxi/channels/contract/plugin/adapters/identity_resolver_adapter.py index 0a9649e6..a4be3443 100644 --- a/backend/package/yuxi/channels/contract/plugin/adapters/identity_resolver_adapter.py +++ b/backend/package/yuxi/channels/contract/plugin/adapters/identity_resolver_adapter.py @@ -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"] diff --git a/backend/package/yuxi/channels/contract/plugin/adapters/inbound_adapter.py b/backend/package/yuxi/channels/contract/plugin/adapters/inbound_adapter.py index 2be99477..362fb1ab 100644 --- a/backend/package/yuxi/channels/contract/plugin/adapters/inbound_adapter.py +++ b/backend/package/yuxi/channels/contract/plugin/adapters/inbound_adapter.py @@ -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"] diff --git a/backend/package/yuxi/channels/contract/plugin/adapters/lifecycle_adapter.py b/backend/package/yuxi/channels/contract/plugin/adapters/lifecycle_adapter.py index 20659c4a..f5565dc2 100644 --- a/backend/package/yuxi/channels/contract/plugin/adapters/lifecycle_adapter.py +++ b/backend/package/yuxi/channels/contract/plugin/adapters/lifecycle_adapter.py @@ -15,7 +15,6 @@ from yuxi.channels.contract.dtos.channel import ( ) from yuxi.channels.contract.errors import NotImplementedError - __all__ = ["LifecycleAdapter"] diff --git a/backend/package/yuxi/channels/contract/plugin/adapters/login_adapter.py b/backend/package/yuxi/channels/contract/plugin/adapters/login_adapter.py index bedf0a89..72ba47ac 100644 --- a/backend/package/yuxi/channels/contract/plugin/adapters/login_adapter.py +++ b/backend/package/yuxi/channels/contract/plugin/adapters/login_adapter.py @@ -17,7 +17,6 @@ from yuxi.channels.contract.dtos.login import ( ) from yuxi.channels.contract.errors import NotImplementedError - __all__ = ["LoginAdapter"] diff --git a/backend/package/yuxi/channels/contract/plugin/adapters/mention_adapter.py b/backend/package/yuxi/channels/contract/plugin/adapters/mention_adapter.py index 1534932b..68028fbc 100644 --- a/backend/package/yuxi/channels/contract/plugin/adapters/mention_adapter.py +++ b/backend/package/yuxi/channels/contract/plugin/adapters/mention_adapter.py @@ -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"] diff --git a/backend/package/yuxi/channels/contract/plugin/adapters/message_ops_adapter.py b/backend/package/yuxi/channels/contract/plugin/adapters/message_ops_adapter.py index ddcd733e..510d0c97 100644 --- a/backend/package/yuxi/channels/contract/plugin/adapters/message_ops_adapter.py +++ b/backend/package/yuxi/channels/contract/plugin/adapters/message_ops_adapter.py @@ -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"] diff --git a/backend/package/yuxi/channels/contract/plugin/adapters/outbound_adapter.py b/backend/package/yuxi/channels/contract/plugin/adapters/outbound_adapter.py index 15a5f816..7578ce9c 100644 --- a/backend/package/yuxi/channels/contract/plugin/adapters/outbound_adapter.py +++ b/backend/package/yuxi/channels/contract/plugin/adapters/outbound_adapter.py @@ -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"] diff --git a/backend/package/yuxi/channels/contract/plugin/adapters/probeable_adapter.py b/backend/package/yuxi/channels/contract/plugin/adapters/probeable_adapter.py index 89deef04..4f19a765 100644 --- a/backend/package/yuxi/channels/contract/plugin/adapters/probeable_adapter.py +++ b/backend/package/yuxi/channels/contract/plugin/adapters/probeable_adapter.py @@ -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"] diff --git a/backend/package/yuxi/channels/contract/plugin/adapters/puller_adapter.py b/backend/package/yuxi/channels/contract/plugin/adapters/puller_adapter.py index 24b10880..3a02493e 100644 --- a/backend/package/yuxi/channels/contract/plugin/adapters/puller_adapter.py +++ b/backend/package/yuxi/channels/contract/plugin/adapters/puller_adapter.py @@ -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"] diff --git a/backend/package/yuxi/channels/contract/plugin/adapters/rich_message_adapter.py b/backend/package/yuxi/channels/contract/plugin/adapters/rich_message_adapter.py index ce656a37..b109c6d7 100644 --- a/backend/package/yuxi/channels/contract/plugin/adapters/rich_message_adapter.py +++ b/backend/package/yuxi/channels/contract/plugin/adapters/rich_message_adapter.py @@ -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"] diff --git a/backend/package/yuxi/channels/contract/plugin/adapters/session_adapter.py b/backend/package/yuxi/channels/contract/plugin/adapters/session_adapter.py index 8b444834..0957267e 100644 --- a/backend/package/yuxi/channels/contract/plugin/adapters/session_adapter.py +++ b/backend/package/yuxi/channels/contract/plugin/adapters/session_adapter.py @@ -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"] diff --git a/backend/package/yuxi/channels/contract/plugin/adapters/status_adapter.py b/backend/package/yuxi/channels/contract/plugin/adapters/status_adapter.py index cf2f16f4..dfaca4b0 100644 --- a/backend/package/yuxi/channels/contract/plugin/adapters/status_adapter.py +++ b/backend/package/yuxi/channels/contract/plugin/adapters/status_adapter.py @@ -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"] diff --git a/backend/package/yuxi/channels/contract/plugin/adapters/stream_connector_adapter.py b/backend/package/yuxi/channels/contract/plugin/adapters/stream_connector_adapter.py index 87052c25..99a459cd 100644 --- a/backend/package/yuxi/channels/contract/plugin/adapters/stream_connector_adapter.py +++ b/backend/package/yuxi/channels/contract/plugin/adapters/stream_connector_adapter.py @@ -10,7 +10,6 @@ from typing import Any, Protocol, runtime_checkable from yuxi.channels.contract.dtos.transport import StreamConfig, StreamConnection from yuxi.channels.contract.errors import NotImplementedError - __all__ = ["StreamConnectorAdapter"] diff --git a/backend/package/yuxi/channels/contract/plugin/adapters/streaming_adapter.py b/backend/package/yuxi/channels/contract/plugin/adapters/streaming_adapter.py index d125265c..799979d8 100644 --- a/backend/package/yuxi/channels/contract/plugin/adapters/streaming_adapter.py +++ b/backend/package/yuxi/channels/contract/plugin/adapters/streaming_adapter.py @@ -16,7 +16,6 @@ from yuxi.channels.contract.dtos.streaming import ( ) from yuxi.channels.contract.errors import NotImplementedError - __all__ = ["StreamingAdapter"] diff --git a/backend/package/yuxi/channels/contract/plugin/adapters/tools_adapter.py b/backend/package/yuxi/channels/contract/plugin/adapters/tools_adapter.py index fd8534e1..9adba425 100644 --- a/backend/package/yuxi/channels/contract/plugin/adapters/tools_adapter.py +++ b/backend/package/yuxi/channels/contract/plugin/adapters/tools_adapter.py @@ -11,7 +11,6 @@ from typing import Any, Protocol, runtime_checkable from yuxi.channels.contract.dtos.tools import ChannelTool, ToolResult from yuxi.channels.contract.errors import NotImplementedError - __all__ = ["ToolsAdapter"] diff --git a/backend/package/yuxi/channels/contract/plugin/adapters/webhook_test_adapter.py b/backend/package/yuxi/channels/contract/plugin/adapters/webhook_test_adapter.py index e93acd58..b4ddbde5 100644 --- a/backend/package/yuxi/channels/contract/plugin/adapters/webhook_test_adapter.py +++ b/backend/package/yuxi/channels/contract/plugin/adapters/webhook_test_adapter.py @@ -16,7 +16,6 @@ from typing import Any, Protocol, runtime_checkable from yuxi.channels.contract.dtos.channel import ChannelAccount, WebhookTestResult from yuxi.channels.contract.errors import NotImplementedError - __all__ = ["WebhookTestAdapter"] diff --git a/backend/package/yuxi/channels/contract/plugin/adapters/whitelist_adapter.py b/backend/package/yuxi/channels/contract/plugin/adapters/whitelist_adapter.py index 8e2535f0..a9fedd79 100644 --- a/backend/package/yuxi/channels/contract/plugin/adapters/whitelist_adapter.py +++ b/backend/package/yuxi/channels/contract/plugin/adapters/whitelist_adapter.py @@ -11,7 +11,6 @@ from typing import Any, Protocol, runtime_checkable from yuxi.channels.contract.dtos.whitelist import WhitelistEntry, WhitelistPolicyType from yuxi.channels.contract.errors import NotImplementedError - __all__ = ["WhitelistAdapter"] diff --git a/backend/package/yuxi/channels/contract/plugin/adapters/wizard_adapter.py b/backend/package/yuxi/channels/contract/plugin/adapters/wizard_adapter.py index 8babbb52..325cf195 100644 --- a/backend/package/yuxi/channels/contract/plugin/adapters/wizard_adapter.py +++ b/backend/package/yuxi/channels/contract/plugin/adapters/wizard_adapter.py @@ -15,7 +15,6 @@ from yuxi.channels.contract.dtos.wizard import ( ) from yuxi.channels.contract.errors import NotImplementedError - __all__ = ["WizardAdapter"] diff --git a/backend/package/yuxi/channels/contract/plugin/extension_point.py b/backend/package/yuxi/channels/contract/plugin/extension_point.py index 5f4e6ced..839811d5 100644 --- a/backend/package/yuxi/channels/contract/plugin/extension_point.py +++ b/backend/package/yuxi/channels/contract/plugin/extension_point.py @@ -182,7 +182,7 @@ class Stage(Protocol): 返回: True 表示成功,False 或抛出异常表示失败。 - @consistency: 阶段处理(stage-processed),按 ``Stage.reads`` / ``Stage.writes`` 元信息读写管道上下文;失败时按 ``Stage.failure`` 策略处理。 + @consistency: 阶段处理(stage-processed),按 ``Stage.reads`` / ``Stage.writes`` 读写上下文;失败按 ``Stage.failure`` 策略处理。 @idempotent: 取决于 ``Stage.idempotent`` 字段声明,由阶段实现者明确标注。 """ ... diff --git a/backend/package/yuxi/channels/contract/plugin/lifecycle.py b/backend/package/yuxi/channels/contract/plugin/lifecycle.py index c9ebabae..81d62c2a 100644 --- a/backend/package/yuxi/channels/contract/plugin/lifecycle.py +++ b/backend/package/yuxi/channels/contract/plugin/lifecycle.py @@ -181,7 +181,7 @@ class LifecycleHookHandler(Protocol): 参数: error: 错误描述。 - @consistency: 状态机驱动(state-machine),钩子完成后状态机推进至 FAILED 并触发降级;钩子自身失败不得阻断降级流程。 + @consistency: 状态机驱动(state-machine),钩子完成后状态机推进至 FAILED 并触发降级;钩子失败不阻断降级。 @idempotent: True — 重复通知失败应安全,仅记录状态与清理资源,无累积副作用。 """ ... diff --git a/backend/package/yuxi/channels/contract/policy/config_schema.py b/backend/package/yuxi/channels/contract/policy/config_schema.py index 6b707be9..b2d7f39d 100644 --- a/backend/package/yuxi/channels/contract/policy/config_schema.py +++ b/backend/package/yuxi/channels/contract/policy/config_schema.py @@ -15,7 +15,7 @@ from __future__ import annotations from yuxi.channels.contract.dtos.config import ConfigField # 配置 schema:全部配置字段的完整元数据(FR-37 配置热更新)。 -# 38 个可热更新字段 + 12 个不可热更新字段,共 50 个。 +# 40 个可热更新字段 + 13 个不可热更新字段,共 53 个。 # 字段顺序:先可热更新(hot_reloadable=True),后不可热更新(hot_reloadable=False)。 CONFIG_SCHEMA: tuple[ConfigField, ...] = ( # === 可热更新字段(hot_reloadable=True)=== @@ -98,6 +98,19 @@ CONFIG_SCHEMA: tuple[ConfigField, ...] = ( ConfigField(key="credential_failure_threshold", type="int", required=False, default=3, hot_reloadable=True), # 凭证失效计数窗口(秒)。窗口外的失效计数被重置,避免历史故障永久影响账号状态。 ConfigField(key="credential_failure_window_seconds", type="int", required=False, default=300, hot_reloadable=True), + # 审计日志保留策略(全局级,scope=GLOBAL)。JSON 格式: + # {"retention_days": int, "cleanup_interval_hours": int} + # 控制审计日志的保留天数与清理周期,由审计清理任务读取。 + ConfigField( + key="audit_retention_policy", + type="json", + required=False, + default={"retention_days": 90, "cleanup_interval_hours": 24}, + hot_reloadable=True, + ), + # 扫码登录重定向 URI(全局级,scope=GLOBAL)。扫码登录成功后的前端跳转地址, + # 为空时使用默认跳转行为。 + ConfigField(key="qr_login_redirect_uri", type="str", required=False, default="", hot_reloadable=True), # === 不可热更新字段(hot_reloadable=False)=== ConfigField(key="webhook_port", type="int", required=False, default=None, hot_reloadable=False), ConfigField(key="webhook_secret", type="str", required=False, default=None, hot_reloadable=False), @@ -121,6 +134,13 @@ CONFIG_SCHEMA: tuple[ConfigField, ...] = ( ConfigField(key="login_status", type="str", required=False, default="unlogged", hot_reloadable=False), # 凭证版本号,每次 rotateCredentials 自增。用于乐观锁控制与缓存失效判定。 ConfigField(key="credential_version", type="int", required=False, default=0, hot_reloadable=False), + # FR17-P0-3 已应用迁移列表(ACCOUNT 作用域,target=channel_type 或 + # {channel_type}:{account_id})。由 PluginLifecycleManager 在插件 onStart + # 成功后追加 manifest.version 标记配置已加载,DoctorService 只读不写。 + # hot_reloadable=False:非业务策略,启动期写入,无需热更新。 + # default=[] 让首次加载时 ConfigPort.get 返回空列表而非 NotFoundError, + # 避免 PluginLifecycleManager 与 DoctorService 的 catch 块接不到异常。 + ConfigField(key="applied_migrations", type="json", required=False, default=[], hot_reloadable=False), ) # 可热更新的配置项键集合(FR-37),从 CONFIG_SCHEMA 派生。 diff --git a/backend/package/yuxi/channels/core/event/channel.py b/backend/package/yuxi/channels/core/event/channel.py index 7e545ef4..208de073 100644 --- a/backend/package/yuxi/channels/core/event/channel.py +++ b/backend/package/yuxi/channels/core/event/channel.py @@ -46,7 +46,7 @@ class ChannelDegraded(DomainEventBase): def _serializePayload(self) -> dict[str, Any]: return { - "channel_type": self.channel_type.value, + "channel_type": self.channel_type, "account_id": self.account_id, "reason": self.reason, } @@ -75,7 +75,7 @@ class ChannelRecovered(DomainEventBase): def _serializePayload(self) -> dict[str, Any]: return { - "channel_type": self.channel_type.value, + "channel_type": self.channel_type, "account_id": self.account_id, } @@ -102,7 +102,7 @@ class ChannelAccountOnline(DomainEventBase): def _serializePayload(self) -> dict[str, Any]: return { - "channel_type": self.channel_type.value, + "channel_type": self.channel_type, "account_id": self.account_id, } @@ -134,7 +134,7 @@ class ChannelAccountOffline(DomainEventBase): def _serializePayload(self) -> dict[str, Any]: return { - "channel_type": self.channel_type.value, + "channel_type": self.channel_type, "account_id": self.account_id, "reason": self.reason, } @@ -175,7 +175,7 @@ class TransportErrorOccurred(DomainEventBase): def _serializePayload(self) -> dict[str, Any]: return { - "channel_type": self.channel_type.value, + "channel_type": self.channel_type, "account_id": self.account_id, "error_category": self.error_category, "error_code": self.error_code, diff --git a/backend/package/yuxi/channels/core/model/service_account.py b/backend/package/yuxi/channels/core/model/service_account.py index f69cef8f..d4cd7962 100644 --- a/backend/package/yuxi/channels/core/model/service_account.py +++ b/backend/package/yuxi/channels/core/model/service_account.py @@ -14,6 +14,8 @@ from datetime import UTC, datetime from yuxi.channels.contract.dtos.channel import ChannelType from yuxi.channels.contract.dtos.service_account import ( CreateServiceAccountCmd, +) +from yuxi.channels.contract.dtos.service_account import ( ServiceAccount as ServiceAccountDto, ) @@ -75,9 +77,9 @@ class ServiceAccount: 返回: ServiceAccount 实例,created_at 为当前 UTC 时间。 """ - uid = f"svc:channel:{cmd.channel_type.value}:{cmd.account_id}" + uid = f"svc:channel:{cmd.channel_type}:{cmd.account_id}" account_id_short = hashlib.sha256(cmd.account_id.encode("utf-8")).hexdigest()[:8] - username = f"svc_channel_{cmd.channel_type.value}_{account_id_short}" + username = f"svc_channel_{cmd.channel_type}_{account_id_short}" return ServiceAccount( uid=uid, username=username, diff --git a/backend/package/yuxi/channels/core/registry/plugin_registry.py b/backend/package/yuxi/channels/core/registry/plugin_registry.py index 9e951869..978db55f 100644 --- a/backend/package/yuxi/channels/core/registry/plugin_registry.py +++ b/backend/package/yuxi/channels/core/registry/plugin_registry.py @@ -141,6 +141,18 @@ class PluginRegistry: """ return list(self._plugins.values()) + def listChannelTypes(self) -> list[ChannelType]: + """返回所有已注册的渠道类型列表。 + + 供通用命令注册等场景遍历已加载插件的渠道类型,避免在框架层 + 维护一份白名单枚举。 + + 返回: + 已注册渠道类型列表副本。 + """ + with self._lock: + return list(self._channel_plugins.keys()) + def listPluginsByState(self, state: LifecycleState) -> list[PluginManifest]: """返回指定生命周期状态的插件清单列表。 diff --git a/backend/package/yuxi/channels/core/service/account_lifecycle_service.py b/backend/package/yuxi/channels/core/service/account_lifecycle_service.py index bc9979ab..08f5cebc 100644 --- a/backend/package/yuxi/channels/core/service/account_lifecycle_service.py +++ b/backend/package/yuxi/channels/core/service/account_lifecycle_service.py @@ -120,7 +120,7 @@ class AccountLifecycleService: await self._logger.warn( f"resolveAccountId unexpected error: {type(exc).__name__}: {exc}", trace_id=trace_id, - channel_type=channel_type.value, + channel_type=channel_type, ) raise InternalError( cause=exc, @@ -159,7 +159,7 @@ class AccountLifecycleService: await self._logger.warn( f"applyAccountConfig unexpected error: {type(exc).__name__}: {exc}", trace_id=trace_id, - channel_type=channel_type.value, + channel_type=channel_type, ) raise InternalError( cause=exc, @@ -229,10 +229,8 @@ class AccountLifecycleService: adapter = self._registry.get(channel_type) if adapter is None: raise NotImplementedError( - f"lifecycle/{channel_type.value}", - message=( - f"channel {channel_type.value} has no lifecycle adapter, cannot resolve account ID or apply config" - ), + f"lifecycle/{channel_type}", + message=(f"channel {channel_type} has no lifecycle adapter, cannot resolve account ID or apply config"), ) return adapter @@ -261,7 +259,7 @@ class AccountLifecycleService: await asyncio.wait_for(method(account), timeout=timeout) await self._logger.info( f"lifecycle callback succeeded: hook={hook}, " - f"channel_type={channel_type.value}, " + f"channel_type={channel_type}, " f"account_id={account.account_id}", trace_id=trace_id, ) @@ -270,7 +268,7 @@ class AccountLifecycleService: msg = f"timeout after {timeout}s" await self._logger.warn( f"lifecycle callback timeout: hook={hook}, " - f"channel_type={channel_type.value}, " + f"channel_type={channel_type}, " f"account_id={account.account_id}, {msg}", trace_id=trace_id, ) @@ -278,7 +276,7 @@ class AccountLifecycleService: except LifecycleHookError as exc: await self._logger.warn( f"lifecycle callback failed: hook={hook}, " - f"channel_type={channel_type.value}, " + f"channel_type={channel_type}, " f"account_id={account.account_id}, reason={exc.reason}", trace_id=trace_id, ) @@ -287,7 +285,7 @@ class AccountLifecycleService: msg = f"{type(exc).__name__}: {exc}" await self._logger.warn( f"lifecycle callback dependency error: hook={hook}, " - f"channel_type={channel_type.value}, " + f"channel_type={channel_type}, " f"account_id={account.account_id}, {msg}", trace_id=trace_id, ) @@ -296,7 +294,7 @@ class AccountLifecycleService: msg = f"unexpected error: {type(exc).__name__}: {exc}" await self._logger.error( f"lifecycle callback unexpected error: hook={hook}, " - f"channel_type={channel_type.value}, " + f"channel_type={channel_type}, " f"account_id={account.account_id}, {msg}", trace_id=trace_id, ) diff --git a/backend/package/yuxi/channels/core/service/common_command_adapter.py b/backend/package/yuxi/channels/core/service/common_command_adapter.py index 3f322858..8c6fa863 100644 --- a/backend/package/yuxi/channels/core/service/common_command_adapter.py +++ b/backend/package/yuxi/channels/core/service/common_command_adapter.py @@ -82,16 +82,21 @@ COMMON_COMMANDS: tuple[ChannelCommand, ...] = ( COMMON_COMMAND_NAMES: frozenset[str] = frozenset(cmd.name for cmd in COMMON_COMMANDS) -def registerCommonCommands(registry: CommandRegistryTable) -> None: +def registerCommonCommands( + registry: CommandRegistryTable, + channel_types: list[ChannelType] | None = None, +) -> None: """将通用命令注册到命令注册表。 - 遍历所有已注册的渠道类型,为每个渠道类型注册 6 个通用命令。若渠道类型 + 遍历指定的渠道类型列表,为每个渠道类型注册 6 个通用命令。若渠道类型 下已存在同名命令(渠道特定命令),跳过该命令的注册。 参数: registry: 命令注册表。 + channel_types: 需要注册通用命令的渠道类型列表。为 None 时不注册 + 任何命令(由调用方负责传入已加载插件的渠道类型)。 """ - for channel_type in ChannelType: + for channel_type in channel_types or []: existing = {cmd.name for cmd in registry.findCommands(channel_type)} for command in COMMON_COMMANDS: if command.name not in existing: @@ -300,10 +305,10 @@ class CommonCommandAdapter: """ channel_type = context.get("channel_type") account = context.get("account_id", "") - # context 由命令执行管道填充,channel_type 应为 ChannelType 枚举。 - # 缺失时使用占位 "unknown" 保证用法文本可读,不构造无效端点。 - if isinstance(channel_type, ChannelType): - type_value = channel_type.value + # context 由命令执行管道填充,channel_type 应为 ChannelType(str 子类)。 + # 缺失或空值时使用占位 "unknown" 保证用法文本可读,不构造无效端点。 + if isinstance(channel_type, str) and channel_type: + type_value = str(channel_type) else: type_value = "unknown" base = f"/api/channels/{type_value}/{account}/allowlist" diff --git a/backend/package/yuxi/channels/core/service/credential_service.py b/backend/package/yuxi/channels/core/service/credential_service.py index 30a4ec50..4c802763 100644 --- a/backend/package/yuxi/channels/core/service/credential_service.py +++ b/backend/package/yuxi/channels/core/service/credential_service.py @@ -178,7 +178,7 @@ class CredentialService: if not credentials: raise CredentialAcquireFailedError( account_id, - channel_type.value, + channel_type, "qr login succeeded but no credentials returned by adapter", ) @@ -276,7 +276,7 @@ class CredentialService: # OAuth 凭证获取流程暂未使用(wechat_ilink / feishu 均无 OAuth 渠道), # 具体适配器调用在 OAuth 渠道接入时按需补充。 raise NotImplementedError( - f"oauth:{channel_type.value}", + f"oauth:{channel_type}", message=( "OAuth credential acquire not yet implemented (P0 framework only, pending OAuth channel onboarding)" ), diff --git a/backend/package/yuxi/channels/core/service/dm_security_guard.py b/backend/package/yuxi/channels/core/service/dm_security_guard.py index b06bbc48..848ec44d 100644 --- a/backend/package/yuxi/channels/core/service/dm_security_guard.py +++ b/backend/package/yuxi/channels/core/service/dm_security_guard.py @@ -121,7 +121,7 @@ class DmSecurityGuard: # 记录日志以满足 INV-7(禁止静默吞错)。 await self._logger.info( "pairing create conflict, retrying getActivePairing", - channel_type=channel_type.value, + channel_type=channel_type, account_id=account_id, peer_id=peer_id, error=str(exc), diff --git a/backend/package/yuxi/channels/core/service/doctor_service.py b/backend/package/yuxi/channels/core/service/doctor_service.py index a6625dec..60697377 100644 --- a/backend/package/yuxi/channels/core/service/doctor_service.py +++ b/backend/package/yuxi/channels/core/service/doctor_service.py @@ -103,8 +103,8 @@ class DoctorService: adapter = self._adapter_registry.get(channel_type) if adapter is None: raise NotImplementedError( - f"doctor/{channel_type.value}", - message=f"channel {channel_type.value} does not support doctor", + f"doctor/{channel_type}", + message=f"channel {channel_type} does not support doctor", trace_id=trace_id, ) return adapter @@ -316,7 +316,7 @@ class DoctorService: except DependencyError as exc: await self._logger.warn( "doctor autoFix failed", - channel_type=channel_type.value, + channel_type=channel_type, account_id=account_id, check_id=check_id, error=str(exc), @@ -674,12 +674,12 @@ class DoctorService: value = await self._config_port.get( key, scope=ConfigScope.ACCOUNT, - target=f"{channel_type.value}:{account_id}", + target=f"{channel_type}:{account_id}", ) except ConfigValidationError as exc: await self._logger.warn( "account config read failed, returning None", - channel_type=channel_type.value, + channel_type=channel_type, account_id=account_id, key=key, error=str(exc), @@ -712,12 +712,12 @@ class DoctorService: value = await self._config_port.get( _APPLIED_MIGRATIONS_KEY, scope=ConfigScope.ACCOUNT, - target=f"{channel_type.value}:{account_id}", + target=f"{channel_type}:{account_id}", ) except ConfigValidationError as exc: await self._logger.warn( "applied migrations read failed, returning empty", - channel_type=channel_type.value, + channel_type=channel_type, account_id=account_id, key=_APPLIED_MIGRATIONS_KEY, error=str(exc), diff --git a/backend/package/yuxi/channels/core/service/identity_resolver.py b/backend/package/yuxi/channels/core/service/identity_resolver.py index f956a580..69a325ed 100644 --- a/backend/package/yuxi/channels/core/service/identity_resolver.py +++ b/backend/package/yuxi/channels/core/service/identity_resolver.py @@ -96,7 +96,7 @@ class IdentityResolver: 返回: ``IdentityResolveResult``,包括解析成功与 channel_guest 两种情况。 """ - cache_key = "identity:" + cmd.channel_type.value + ":" + cmd.account_id + ":" + cmd.peer_id + cache_key = "identity:" + cmd.channel_type + ":" + cmd.account_id + ":" + cmd.peer_id # 缓存读取:故障降级到无缓存模式 cached = await self._getCache(cache_key) diff --git a/backend/package/yuxi/channels/core/service/qr_login_service.py b/backend/package/yuxi/channels/core/service/qr_login_service.py index aed53260..8f0c7942 100644 --- a/backend/package/yuxi/channels/core/service/qr_login_service.py +++ b/backend/package/yuxi/channels/core/service/qr_login_service.py @@ -183,8 +183,8 @@ class QrLoginService: adapter = self._registry.get(channel_type) if adapter is None: raise NotImplementedError( - f"login/{channel_type.value}", - message=f"channel {channel_type.value} does not support qr login", + f"login/{channel_type}", + message=f"channel {channel_type} does not support qr login", trace_id=trace_id, ) return adapter @@ -261,7 +261,7 @@ class QrLoginService: current = await self.getLoginStatus(channel_type, account_id) if current == LoginStatus.LOGGED_IN: raise ConflictError( - f"login:{channel_type.value}:{account_id}", + f"login:{channel_type}:{account_id}", trace_id=trace_id, ) existing_id = self._account_sessions.get((channel_type, account_id)) @@ -273,7 +273,7 @@ class QrLoginService: and existing.status in (QrSessionStatus.PENDING, QrSessionStatus.SCANNED) ): raise ConflictError( - f"qr_session:{channel_type.value}:{account_id}", + f"qr_session:{channel_type}:{account_id}", trace_id=trace_id, ) else: @@ -288,11 +288,11 @@ class QrLoginService: await self._logger.warn( f"loginWithQrStart unexpected error: {type(exc).__name__}: {exc}", trace_id=trace_id, - channel_type=channel_type.value, + channel_type=channel_type, ) # DependencyError 签名: (dep: str, cause: Error|Exception, *, trace_id=None) raise DependencyError( - f"loginWithQrStart:{channel_type.value}", + f"loginWithQrStart:{channel_type}", exc, trace_id=trace_id, ) from exc @@ -385,11 +385,11 @@ class QrLoginService: await self._logger.warn( f"loginWithQrWait unexpected error: {type(exc).__name__}: {exc}", trace_id=trace_id, - channel_type=channel_type.value, + channel_type=channel_type, ) # DependencyError 签名: (dep: str, cause: Error|Exception, *, trace_id=None) raise DependencyError( - f"loginWithQrWait:{channel_type.value}", + f"loginWithQrWait:{channel_type}", exc, trace_id=trace_id, ) from exc @@ -528,11 +528,11 @@ class QrLoginService: await self._logger.warn( f"loginWithQrStart unexpected error: {type(exc).__name__}: {exc}", trace_id=trace_id, - channel_type=channel_type.value, + channel_type=channel_type, ) # DependencyError 签名: (dep: str, cause: Error|Exception, *, trace_id=None) raise DependencyError( - f"loginWithQrStart:{channel_type.value}", + f"loginWithQrStart:{channel_type}", exc, trace_id=trace_id, ) from exc @@ -587,10 +587,10 @@ class QrLoginService: await self._logger.warn( f"logout unexpected error: {type(exc).__name__}: {exc}", trace_id=trace_id, - channel_type=channel_type.value, + channel_type=channel_type, ) raise DependencyError( - f"logout:{channel_type.value}", + f"logout:{channel_type}", exc, trace_id=trace_id, ) from exc @@ -637,8 +637,8 @@ class QrLoginService: # 通过守卫方法检查适配器是否支持 forceLogout(避免 hasattr 误判) if not adapter.supportsForceLogout(): raise NotImplementedError( - f"login/force_logout:{channel_type.value}", - message=f"channel {channel_type.value} does not support forceLogout", + f"login/force_logout:{channel_type}", + message=f"channel {channel_type} does not support forceLogout", trace_id=trace_id, ) @@ -662,10 +662,10 @@ class QrLoginService: await self._logger.warn( f"forceLogout unexpected error: {type(exc).__name__}: {exc}", trace_id=trace_id, - channel_type=channel_type.value, + channel_type=channel_type, ) raise DependencyError( - f"force_logout:{channel_type.value}", + f"force_logout:{channel_type}", exc, trace_id=trace_id, ) from exc diff --git a/backend/package/yuxi/channels/core/service/whitelist_evaluator.py b/backend/package/yuxi/channels/core/service/whitelist_evaluator.py index 31028dbc..e49067b2 100644 --- a/backend/package/yuxi/channels/core/service/whitelist_evaluator.py +++ b/backend/package/yuxi/channels/core/service/whitelist_evaluator.py @@ -116,7 +116,7 @@ class WhitelistEvaluator: # 记录错误日志,不向上抛出 await self._logger.error( "nested whitelist evaluation failed, defaulting to deny", - channel_type=channel_type.value, + channel_type=channel_type, account_id=account_id, peer_id=peer_id, chat_type=chat_type, diff --git a/backend/package/yuxi/channels/core/service/wizard_service.py b/backend/package/yuxi/channels/core/service/wizard_service.py index b848a69b..a75ad3d4 100644 --- a/backend/package/yuxi/channels/core/service/wizard_service.py +++ b/backend/package/yuxi/channels/core/service/wizard_service.py @@ -30,7 +30,6 @@ from yuxi.channels.contract.errors import ( RuleViolationError, ValidationError, ) -from yuxi.channels.contract.errors.server import NotImplementedError from yuxi.channels.contract.plugin.adapters.wizard_adapter import WizardAdapter from yuxi.channels.contract.ports.driven.config_port import ConfigPort from yuxi.channels.contract.ports.driven.logger_port import LoggerPort @@ -54,10 +53,12 @@ _OAUTH_PENDING_KEY: str = "_oauth_pending" #: OAuth 回调响应中需脱敏的敏感字段名集合。 #: 这些字段由适配器 ``handleOAuthCallback`` 产出(如 user_access_token), #: 已存储到向导状态供后续使用,无需明文返回给前端。 -_OAUTH_SENSITIVE_FIELDS: frozenset[str] = frozenset({ - "user_access_token", - "user_refresh_token", -}) +_OAUTH_SENSITIVE_FIELDS: frozenset[str] = frozenset( + { + "user_access_token", + "user_refresh_token", + } +) class WizardService: @@ -116,13 +117,13 @@ class WizardService: 渠道向导适配器实例。 抛出: - NotImplementedError: 渠道未注册向导适配器(501)。 + NotFoundError: 渠道未注册向导适配器(404)。 """ adapter = self._adapter_registry.get(channel_type) if adapter is None: - raise NotImplementedError( - f"wizard/{channel_type.value}", - message=f"channel {channel_type.value} does not support wizard", + raise NotFoundError( + "wizard_adapter", + f"{channel_type}", trace_id=trace_id, ) return adapter @@ -168,7 +169,7 @@ class WizardService: ``fields`` 与 ``skippable``。 抛出: - NotImplementedError: 渠道不支持向导(501)。 + NotFoundError: 渠道不支持向导(404)。 """ adapter = self._getAdapter(channel_type, trace_id=trace_id) steps = await adapter.getWizardSteps() @@ -201,7 +202,7 @@ class WizardService: "config_patch": dict | None}``。 抛出: - NotImplementedError: 渠道不支持向导(501)。 + NotFoundError: 渠道不支持向导(404)。 NotFoundError: 步骤不存在(404)。 """ adapter = self._getAdapter(channel_type, trace_id=trace_id) @@ -236,7 +237,7 @@ class WizardService: 扩展预留。 抛出: - NotImplementedError: 渠道不支持向导(501)。 + NotFoundError: 渠道不支持向导(404)。 NotFoundError: 步骤不存在(404)。 ValidationError: 步骤校验失败(400)。 """ @@ -288,7 +289,7 @@ class WizardService: ``{"config": 最终配置字典}``。 抛出: - NotImplementedError: 渠道不支持向导(501)。 + NotFoundError: 渠道不支持向导(404)。 NotFoundError: ``patches`` 中存在未声明的 ``step_id``(404)。 RuleViolationError: ``patches`` 中补丁缺少 ``step_id`` 或 ``values`` 字段(422)。 @@ -330,14 +331,14 @@ class WizardService: current_version = await self._config_port.getVersion( _CHANNEL_CONFIG_KEY, scope=ConfigScope.CHANNEL, - target=channel_type.value, + target=channel_type, ) cmd = UpdateConfigCmd( key=_CHANNEL_CONFIG_KEY, value=merged, operator=operator, scope=ConfigScope.CHANNEL, - target=channel_type.value, + target=channel_type, expected_version=current_version.version, ) await self._config_manager.update(cmd) @@ -369,7 +370,7 @@ class WizardService: ``{"state": str}`` — CSRF 防护参数,前端附加到 OAuth 授权 URL。 抛出: - NotImplementedError: 渠道不支持向导(501)。 + NotFoundError: 渠道不支持向导(404)。 """ self._getAdapter(channel_type, trace_id=trace_id) state = uuid4().hex @@ -418,7 +419,7 @@ class WizardService: ``{"patch": 配置补丁字典}`` — 敏感字段(token)已脱敏为 ``"***"``。 抛出: - NotImplementedError: 渠道不支持向导(501)。 + NotFoundError: 渠道不支持向导(404)。 ValidationError: OAuth 未发起、state 不匹配、redirect_uri 不一致 或令牌交换失败(400)。 """ @@ -485,12 +486,12 @@ class WizardService: value = await self._config_port.get( _WIZARD_STATE_KEY, scope=ConfigScope.CHANNEL, - target=channel_type.value, + target=channel_type, ) except ConfigValidationError as exc: await self._logger.warn( "wizard state read failed, returning empty", - channel_type=channel_type.value, + channel_type=channel_type, key=_WIZARD_STATE_KEY, error=str(exc), trace_id=trace_id, @@ -543,14 +544,14 @@ class WizardService: current_version = await self._config_port.getVersion( _WIZARD_STATE_KEY, scope=ConfigScope.CHANNEL, - target=channel_type.value, + target=channel_type, ) cmd = UpdateConfigCmd( key=_WIZARD_STATE_KEY, value=value, operator=operator, scope=ConfigScope.CHANNEL, - target=channel_type.value, + target=channel_type, expected_version=current_version.version, ) await self._config_manager.update(cmd) @@ -572,14 +573,14 @@ class WizardService: current_version = await self._config_port.getVersion( _WIZARD_STATE_KEY, scope=ConfigScope.CHANNEL, - target=channel_type.value, + target=channel_type, ) cmd = UpdateConfigCmd( key=_WIZARD_STATE_KEY, value={}, operator=operator, scope=ConfigScope.CHANNEL, - target=channel_type.value, + target=channel_type, expected_version=current_version.version, ) await self._config_manager.update(cmd) diff --git a/backend/package/yuxi/channels/infrastructure/channel_use_cases.py b/backend/package/yuxi/channels/infrastructure/channel_use_cases.py index b9605044..4f3dfb3f 100644 --- a/backend/package/yuxi/channels/infrastructure/channel_use_cases.py +++ b/backend/package/yuxi/channels/infrastructure/channel_use_cases.py @@ -315,7 +315,8 @@ class ChannelUseCases: ``ChannelControlService`` 同一实例(第 15 个类型化端口),聚合 PluginRegistry 中插件的 manifest.capabilities 声明。 content_review: 内容审核用例(实现 ContentReviewPort),覆盖管理员 - 主动触发的 dry-run 预审核(CR-01),绑定到 ``ChannelControlService`` 同一实例(第 16 个类型化端口),经控制面管道执行 + 主动触发的 dry-run 预审核(CR-01),绑定到 ``ChannelControlService`` + 同一实例(第 16 个类型化端口),经控制面管道执行 ``content_review/preview`` 操作。 content_review_query: 内容审核历史查询用例(实现 ContentReviewQueryPort), 覆盖审核历史列表与详情查询(CR-02 / CR-03),由独立的 @@ -388,12 +389,12 @@ def _fill_adapter_registry[T]( adapters: 插件注册的适配器列表。 channel_type: 当前渠道类型。 duplicate_rule: 若设置,重复注册时抛错,rule 为 - ``{duplicate_rule}:{channel_type.value}``。 + ``{duplicate_rule}:{channel_type}``。 """ for adapter in adapters: if duplicate_rule is not None and channel_type in registry: raise RuleViolationError( - rule=f"{duplicate_rule}:{channel_type.value}", + rule=f"{duplicate_rule}:{channel_type}", ) registry[channel_type] = adapter @@ -729,8 +730,9 @@ def create_channel_use_cases( # 渠道账号凭证的唯一落库入口,依赖 QrLoginService(QR 凭证获取)+ # ConfigPort(加密存储)+ CachePort(缓存失效)+ PersistencePort # (P1 聚合根持久化)+ LoggerPort。必须在 QrLoginService 之后构造 - # (强依赖),注入到 WizardService(P2 finalize 凭证落库)与 - # AccountHandler(P1 enable/disable 链路凭证撤销)。 + # (强依赖),经 ControlPlanePipeline.create 注入到 AccountHandler + # (P1 enable/disable 链路 OFFLINE→ONLINE 凭证存在性校验)。 + # WizardService 的 P2 finalize 凭证落库为未来规划,当前未消费。 credential_service = CredentialService( config_port=adapters.config, config_manager=config_manager, @@ -852,6 +854,9 @@ def create_channel_use_cases( channel_probe=channel_probe, realtime_metrics=realtime_metrics, service_account_port=service_account_port, + # Task 11: 凭证编排服务,供 AccountHandler P1 enable/disable 链路 + # 凭证存在性校验(OFFLINE→ONLINE 时 getCredentials)。 + credential_service=credential_service, ) # 5. 用例服务(请求级,注入管道与适配器) diff --git a/backend/package/yuxi/channels/infrastructure/factory.py b/backend/package/yuxi/channels/infrastructure/factory.py index 5afca4ea..04d7290d 100644 --- a/backend/package/yuxi/channels/infrastructure/factory.py +++ b/backend/package/yuxi/channels/infrastructure/factory.py @@ -122,7 +122,6 @@ from yuxi.channels.core.registry.plugin_registry import PluginRegistry from yuxi.channels.core.registry.route_match_registry import RouteMatchRegistry from yuxi.channels.core.registry.whitelist_registry import WhitelistRegistry from yuxi.channels.core.service.capability_verifier import CapabilityVerifier -from yuxi.channels.core.service.common_command_adapter import registerCommonCommands from yuxi.channels.core.service.degradation_manager import DegradationManager from yuxi.channels.core.service.explicit_mapping_resolver import ExplicitMappingResolver from yuxi.channels.core.service.phone_number_resolver import PhoneNumberResolver @@ -608,8 +607,9 @@ def _register_di_singletons( # 2. 命令注册表(FR-15)与身份解析器注册表(FR-05) # 由 bootstrap 一次性注册,避免每个请求重复构造解析器实例。 + # 通用命令注册推迟到插件加载完成后由 HostBootstrap 触发 + # (依赖已加载插件的 channel_type 列表)。 command_registry_table = CommandRegistryTable() - registerCommonCommands(command_registry_table) di_container.registerSingleton(CommandRegistryTable, command_registry_table) identity_resolver_registry = IdentityResolverRegistry() @@ -710,7 +710,9 @@ async def create_host_bootstrap( # persistence_port 需要 AsyncSession,创建应用级会话供 OutboxRecoveryScanner # 与 DiagnosticsExporter 共用;queue_port 无需会话。 persistence_db = ensure_schema.AsyncSession() - persistence_port: PersistencePort = ChannelPersistenceAdapter(persistence_db, outbox_config=core.outbox_config, logger=logger) + persistence_port: PersistencePort = ChannelPersistenceAdapter( + persistence_db, outbox_config=core.outbox_config, logger=logger + ) queue_port: QueuePort = ARQQueueAdapter(arq_pool=core.arq_pool, logger=logger) # 3. 注册内置事件订阅者(FR-18/22/30/32/33/34/36) diff --git a/backend/package/yuxi/channels/infrastructure/host_bootstrap.py b/backend/package/yuxi/channels/infrastructure/host_bootstrap.py index 83e82f26..75813d9d 100644 --- a/backend/package/yuxi/channels/infrastructure/host_bootstrap.py +++ b/backend/package/yuxi/channels/infrastructure/host_bootstrap.py @@ -66,6 +66,9 @@ from yuxi.channels.contract.ports.driven.config_port import ConfigPort from yuxi.channels.contract.ports.driven.event_publisher_port import EventPublisherPort from yuxi.channels.contract.ports.driven.logger_port import LoggerPort from yuxi.channels.contract.ports.driven.pingable_port import PingablePort +from yuxi.channels.core.registry.command_registry import CommandRegistryTable +from yuxi.channels.core.registry.plugin_registry import PluginRegistry +from yuxi.channels.core.service.common_command_adapter import registerCommonCommands from yuxi.channels.infrastructure.dependency_injection import ( DependencyInjectionContainer, ) @@ -320,6 +323,9 @@ class HostBootstrap: # 插件失败标记 failed,触发优雅降级,不阻塞其他插件 await self._loadPlugins() + # 4.5 注册通用命令(依赖已加载插件的 channel_type 列表) + await self._registerCommonCommands() + # 5. 装配管道(注入插件阶段) await self._assemblePipelines() @@ -787,6 +793,26 @@ class HostBootstrap: # 不影响本步骤的完成状态。 self._plugins_loaded = True + async def _registerCommonCommands(self) -> None: + """注册通用命令到命令注册表。 + + 从 DI 容器解析 ``PluginRegistry`` 与 ``CommandRegistryTable``, + 遍历已加载插件的 channel_type 列表,为每个渠道类型注册 6 个通用 + 命令(/help、/model、/reset、/about、/allowlist、/approve)。 + 渠道特定命令优先保留,通用命令仅填充未占用的命令名。 + + 本步骤必须在 ``_loadPlugins`` 之后执行,因为 channel_type 列表 + 由插件 manifest 声明,只有在插件加载后才可知。 + """ + plugin_registry = self._di.resolve(PluginRegistry) + command_registry_table = self._di.resolve(CommandRegistryTable) + channel_types = plugin_registry.listChannelTypes() + registerCommonCommands(command_registry_table, channel_types) + await self._logger.info( + f"通用命令注册完成: {len(channel_types)} 个渠道类型", + channel_count=len(channel_types), + ) + async def _loadSinglePlugin(self, manifest: ChannelManifest) -> None: """加载单个插件,失败记录警告并继续。 diff --git a/backend/package/yuxi/channels/infrastructure/host_shutdown.py b/backend/package/yuxi/channels/infrastructure/host_shutdown.py index d5da91e0..2ba55795 100644 --- a/backend/package/yuxi/channels/infrastructure/host_shutdown.py +++ b/backend/package/yuxi/channels/infrastructure/host_shutdown.py @@ -480,13 +480,13 @@ class HostShutdown: try: await adapters.close() await self._logger.info( - f"被驱动适配器已关闭: channel_type={channel_type.value}", - channel_type=channel_type.value, + f"被驱动适配器已关闭: channel_type={channel_type}", + channel_type=channel_type, ) except Exception as e: await self._logger.warn( - f"关闭被驱动适配器失败,继续关闭其他: channel_type={channel_type.value}, error={e}", - channel_type=channel_type.value, + f"关闭被驱动适配器失败,继续关闭其他: channel_type={channel_type}, error={e}", + channel_type=channel_type, error=str(e), ) if not adapters_list: diff --git a/backend/package/yuxi/channels/plugins/feishu/adapters/directory_adapter.py b/backend/package/yuxi/channels/plugins/feishu/adapters/directory_adapter.py index 66764b24..acb86149 100644 --- a/backend/package/yuxi/channels/plugins/feishu/adapters/directory_adapter.py +++ b/backend/package/yuxi/channels/plugins/feishu/adapters/directory_adapter.py @@ -26,6 +26,7 @@ from yuxi.channels.contract.dtos.directory import ( from yuxi.channels.contract.errors import ( DependencyError, NotFoundError, + NotImplementedError, PermissionDeniedError, ) from yuxi.channels.contract.ports.driven.cache_port import CachePort @@ -207,6 +208,22 @@ class FeishuDirectoryAdapter: member_count=int(group_data.get("user_count") or 0), ) + def supportsCacheClear(self) -> bool: + """是否支持目录缓存清理(DIR-CACHE-CLEAR)。 + + 飞书目录适配器未实现缓存清理,返回 ``False``,dispatch handler + 据此直接返回 501 NOT_IMPLEMENTED,不调用 ``clearCache``。 + """ + return False + + async def clearCache(self, scope: str) -> int: + """清理目录缓存。FeishuDirectoryAdapter 不支持此操作。 + + @pre: ``supportsCacheClear()`` 返回 ``True``。本适配器返回 ``False``, + 故不应被调用。 + """ + raise NotImplementedError(operation="clearCache") + def _extract_items(data: dict[str, Any]) -> list[dict[str, Any]]: """从飞书分页响应中提取 items 列表。""" diff --git a/backend/package/yuxi/channels/plugins/feishu/adapters/probeable_adapter.py b/backend/package/yuxi/channels/plugins/feishu/adapters/probeable_adapter.py index 2d72d9f4..97e5a4bf 100644 --- a/backend/package/yuxi/channels/plugins/feishu/adapters/probeable_adapter.py +++ b/backend/package/yuxi/channels/plugins/feishu/adapters/probeable_adapter.py @@ -59,7 +59,7 @@ class FeishuProbeableAdapter: start = time.monotonic() try: accounts = await self._persistence.listChannelAccounts( - ChannelType.FEISHU, + ChannelType("feishu"), limit=1, ) if not accounts: diff --git a/backend/package/yuxi/channels/plugins/feishu/adapters/session_adapter.py b/backend/package/yuxi/channels/plugins/feishu/adapters/session_adapter.py index 46ee2b70..0ad77bf3 100644 --- a/backend/package/yuxi/channels/plugins/feishu/adapters/session_adapter.py +++ b/backend/package/yuxi/channels/plugins/feishu/adapters/session_adapter.py @@ -59,7 +59,7 @@ class FeishuSessionAdapter: topic_id = root_id if root_id else None return SessionInfo( - channel_type=ChannelType.FEISHU, + channel_type=ChannelType("feishu"), account_id=account_id, peer_id=peer_id, chat_type=chat_type, diff --git a/backend/package/yuxi/channels/plugins/feishu/adapters/stream_connector_adapter.py b/backend/package/yuxi/channels/plugins/feishu/adapters/stream_connector_adapter.py index ed4e82f7..c22b7bdc 100644 --- a/backend/package/yuxi/channels/plugins/feishu/adapters/stream_connector_adapter.py +++ b/backend/package/yuxi/channels/plugins/feishu/adapters/stream_connector_adapter.py @@ -77,7 +77,7 @@ class FeishuStreamConnectorAdapter: TransportError: 账户不存在、凭据缺失或连接失败。 """ account = await self._persistence.getChannelAccount( - ChannelType.FEISHU, + ChannelType("feishu"), account_id, ) if account is None: diff --git a/backend/package/yuxi/channels/plugins/feishu/adapters/whitelist_adapter.py b/backend/package/yuxi/channels/plugins/feishu/adapters/whitelist_adapter.py index a2212696..3b7c6751 100644 --- a/backend/package/yuxi/channels/plugins/feishu/adapters/whitelist_adapter.py +++ b/backend/package/yuxi/channels/plugins/feishu/adapters/whitelist_adapter.py @@ -30,7 +30,7 @@ from yuxi.channels.contract.ports.driven.logger_port import LoggerPort from yuxi.channels.contract.ports.driven.persistence_port import PersistencePort _WHITELIST_CONFIG_KEY = "whitelist" -_FEISHU_CHANNEL_TYPE = ChannelType.FEISHU +_FEISHU_CHANNEL_TYPE = ChannelType("feishu") class FeishuWhitelistAdapter: diff --git a/backend/package/yuxi/channels/plugins/feishu/entry.py b/backend/package/yuxi/channels/plugins/feishu/entry.py index 7602ca71..ecdada80 100644 --- a/backend/package/yuxi/channels/plugins/feishu/entry.py +++ b/backend/package/yuxi/channels/plugins/feishu/entry.py @@ -212,7 +212,7 @@ def channel_entry(host: PluginHost) -> PluginManifest: id="com.yuxi.channels.feishu", name="飞书", version="1.0.0", - channel_type=ChannelType.FEISHU, + channel_type=ChannelType("feishu"), provides=("feishu",), entry_module="yuxi.channels.plugins.feishu.entry", capabilities=ChannelCapabilities( diff --git a/backend/package/yuxi/channels/plugins/feishu/error_translator.py b/backend/package/yuxi/channels/plugins/feishu/error_translator.py index a3093ce4..db4fe8d5 100644 --- a/backend/package/yuxi/channels/plugins/feishu/error_translator.py +++ b/backend/package/yuxi/channels/plugins/feishu/error_translator.py @@ -16,9 +16,9 @@ from yuxi.channels.contract.errors import ( DependencyError, Error, NotFoundError, + OperationTimeoutError, PermissionDeniedError, RateLimitError, - OperationTimeoutError, ValidationError, ) diff --git a/backend/package/yuxi/channels/plugins/wechat_ilink/adapters/lifecycle_adapter.py b/backend/package/yuxi/channels/plugins/wechat_ilink/adapters/lifecycle_adapter.py index 369aa560..e367e3f1 100644 --- a/backend/package/yuxi/channels/plugins/wechat_ilink/adapters/lifecycle_adapter.py +++ b/backend/package/yuxi/channels/plugins/wechat_ilink/adapters/lifecycle_adapter.py @@ -166,6 +166,10 @@ class WeChatILinkLifecycleAdapter: account_id=account.account_id, ) + def supportsRotateCredentials(self) -> bool: + """iLink 不支持运行时凭据轮换(bot_token 通过 QR 扫码获取)。""" + return False + async def rotateCredentials( self, account: ChannelAccount, @@ -173,7 +177,9 @@ class WeChatILinkLifecycleAdapter: """轮换账户凭据(iLink 不支持运行时轮换,抛 ``NotImplementedError``)。 iLink bot_token 通过 QR 扫码登录获取,不支持运行时轮换。需要重新 - 扫码登录以获取新凭证。由 dispatch handler 映射为 501 NOT_IMPLEMENTED。 + 扫码登录以获取新凭证。调用方 **必须** 先通过 ``supportsRotateCredentials`` + 检查(返回 ``False``),**不** 调用本方法。本方法体保留 ``NotImplementedError`` + 作为契约默认实现的兜底,由 dispatch handler 映射为 501 NOT_IMPLEMENTED。 """ raise NotImplementedError(operation="rotateCredentials") diff --git a/backend/package/yuxi/channels/plugins/wechat_ilink/adapters/login_adapter.py b/backend/package/yuxi/channels/plugins/wechat_ilink/adapters/login_adapter.py index ee43921f..94cf6b80 100644 --- a/backend/package/yuxi/channels/plugins/wechat_ilink/adapters/login_adapter.py +++ b/backend/package/yuxi/channels/plugins/wechat_ilink/adapters/login_adapter.py @@ -46,9 +46,8 @@ from yuxi.channels.contract.ports.driven.logger_port import LoggerPort from ..ilink_client import ILinkClient -# 扫码状态轮询间隔(秒),与 manifest.json qr_poll_interval_ms 默认值一致 -_QR_POLL_INTERVAL_S = 2.0 -# 默认扫码超时(毫秒),与 manifest.json qr_timeout_ms 默认值一致 +# 配置默认值(与 manifest.json 对应字段一致,ConfigPort 未命中时回退) +_DEFAULT_QR_POLL_INTERVAL_MS = 2000 _DEFAULT_QR_TIMEOUT_MS = 120_000 _DEFAULT_ACCOUNT = "default" # qrcode 令牌编码到 qr_data_url fragment 的前缀(feishu adapter 同模式) @@ -126,7 +125,7 @@ class WeChatILinkLoginAdapter: ``ilink_user_id`` / ``baseurl``,填入 ``QrLoginWaitResult.credentials`` 返回,由 ``CredentialService`` 加密落库;适配器不调用任何 Port 写方法(INV-8)。 - - ``scaned`` / ``wait``:睡眠 2s 后继续轮询。 + - ``scaned`` / ``wait``:睡眠后继续轮询(间隔由 ``qr_poll_interval_ms`` 配置)。 扫码未成功时 ``credentials=None``。超时或查询失败返回等待 / 失败 状态,MUST NOT 抛异常。 @@ -141,7 +140,19 @@ class WeChatILinkLoginAdapter: credentials=None, ) - deadline = time.monotonic() + (timeout_ms or _DEFAULT_QR_TIMEOUT_MS) / 1000.0 + # 超时与轮询间隔从 ConfigPort 读取(支持热更新) + effective_timeout_ms = timeout_ms + if effective_timeout_ms is None: + effective_timeout_ms = await self._client.get_channel_config( + "qr_timeout_ms", + _DEFAULT_QR_TIMEOUT_MS, + ) + poll_interval_ms = await self._client.get_channel_config( + "qr_poll_interval_ms", + _DEFAULT_QR_POLL_INTERVAL_MS, + ) + poll_interval_s = int(poll_interval_ms) / 1000.0 + deadline = time.monotonic() + int(effective_timeout_ms) / 1000.0 redirect_host: str | None = None try: while time.monotonic() < deadline: @@ -149,7 +160,7 @@ class WeChatILinkLoginAdapter: status = resp.get("status", "wait") if status == "confirmed": - return self._build_confirmed_result(acct, resp) + return await self._build_confirmed_result(acct, resp) if status == "scaned_but_redirect": redirect_host = resp.get("redirect_host") @@ -178,7 +189,7 @@ class WeChatILinkLoginAdapter: account_id=acct, ) - await asyncio.sleep(_QR_POLL_INTERVAL_S) + await asyncio.sleep(poll_interval_s) except Exception as exc: await self._logger.warn( "iLink QR login poll failed", @@ -205,6 +216,9 @@ class WeChatILinkLoginAdapter: iLink 无显式登出 API,``logged_out=None``。适配器不清理本地存储 (由 ``CredentialService.revokeCredentials`` 负责),仅返回结果。 适配器不调用任何 Port 写方法(INV-8)。 + + @consistency: 最终一致(eventual),凭证撤销由 CredentialService 控制。 + @idempotent: True — 重复调用返回相同结果,不抛异常。 """ await self._logger.info( "iLink account logout: no channel-side API, local clear delegated to CredentialService", diff --git a/backend/package/yuxi/channels/plugins/wechat_ilink/adapters/outbound_adapter.py b/backend/package/yuxi/channels/plugins/wechat_ilink/adapters/outbound_adapter.py index a62fa428..8a1b1d76 100644 --- a/backend/package/yuxi/channels/plugins/wechat_ilink/adapters/outbound_adapter.py +++ b/backend/package/yuxi/channels/plugins/wechat_ilink/adapters/outbound_adapter.py @@ -35,8 +35,8 @@ _IMAGE_ITEM_TYPE = 2 _VIDEO_ITEM_TYPE = 3 _FILE_ITEM_TYPE = 4 -# 长文本分片阈值(Unicode 字符数) -_MAX_MESSAGE_LENGTH = 4000 +# 长文本分片默认阈值(Unicode 字符数),ConfigPort 未命中时回退 +_DEFAULT_MAX_MESSAGE_LENGTH = 4000 class WeChatILinkOutboundAdapter: @@ -89,6 +89,9 @@ class WeChatILinkOutboundAdapter: - ``item_list`` = ``[{"type": 1, "text_item": {"text": payload.content}}]`` POST ``/ilink/bot/sendmessage``,返回 iLink ``client_id``。 + + @consistency: 最终一致(eventual),渠道侧状态以回传事件为准。 + @idempotent: False — 每次调用生成新 ``client_id``,重复发送会创建多条消息。 """ ctx_token = await self._client.get_context_token(account_id, peer_id) if ctx_token is None: @@ -129,6 +132,9 @@ class WeChatILinkOutboundAdapter: 发送前预校验 ``context_token``,避免文本已发送、媒体因缺 token 失败导致半截消息。每片独立调用 ``sendMessage`` 生成唯一 ``client_id``。 + + @consistency: 最终一致(eventual),部分分片可能成功部分失败。 + @idempotent: False — 每片生成独立 ``client_id``,重试会产生重复分片。 """ # 预校验 context_token,避免半截消息 ctx_token = await self._client.get_context_token(account_id, peer_id) @@ -138,11 +144,19 @@ class WeChatILinkOutboundAdapter: message="context_token_not_found", ) + # 分片阈值从 ConfigPort 读取(支持热更新) + max_length = int( + await self._client.get_channel_config( + "max_message_length", + _DEFAULT_MAX_MESSAGE_LENGTH, + ) + ) + receipts: list[MultiPartReceipt] = [] seq = 0 if payload.content: - if len(payload.content) <= _MAX_MESSAGE_LENGTH: + if len(payload.content) <= max_length: msg_id = await self.sendMessage( account_id, peer_id, @@ -161,10 +175,7 @@ class WeChatILinkOutboundAdapter: ) seq += 1 else: - chunks = [ - payload.content[i : i + _MAX_MESSAGE_LENGTH] - for i in range(0, len(payload.content), _MAX_MESSAGE_LENGTH) - ] + chunks = [payload.content[i : i + max_length] for i in range(0, len(payload.content), max_length)] for chunk in chunks: msg_id = await self.sendMessage( account_id, diff --git a/backend/package/yuxi/channels/plugins/wechat_ilink/adapters/probeable_adapter.py b/backend/package/yuxi/channels/plugins/wechat_ilink/adapters/probeable_adapter.py index 36ce5922..296af8ff 100644 --- a/backend/package/yuxi/channels/plugins/wechat_ilink/adapters/probeable_adapter.py +++ b/backend/package/yuxi/channels/plugins/wechat_ilink/adapters/probeable_adapter.py @@ -2,10 +2,9 @@ 实现 ``ProbeableAdapter`` Protocol,通过 iLink ``/ilink/bot/getconfig`` 端点主动探测渠道 API 可用性(FR-35)。``probe()`` 无 account_id 参数, -通过 ``PersistencePort.listChannelAccounts`` 列出 CUSTOM 类型账户, -按 config 含 ``ilink_bot_id`` 键筛选出 iLink 账户,使用其 ``account_id`` -调用 ``ILinkClient.getconfig``;getconfig 端点基于 ``CachePort`` 中的 -bot_token 鉴权,验证渠道 API 连通性与凭据有效性。 +通过 ``PersistencePort.listChannelAccounts`` 列出 wechat_ilink 类型账户, +取首个账户的 ``account_id`` 调用 ``ILinkClient.getconfig``;getconfig +端点基于 ``CachePort`` 中的 bot_token 鉴权,验证渠道 API 连通性与凭据有效性。 设计要点: - 适配器无状态(stateless,INV-5),仅持有 DI 注入的 ``ILinkClient`` / @@ -16,8 +15,6 @@ bot_token 鉴权,验证渠道 API 连通性与凭据有效性。 统一映射为 ``ProbeResult``)。 - ``probeDeep()`` 为预留扩展点,本适配器不实现,抛 ``NotImplementedError``, 由 dispatch handler 映射为 501 NOT_IMPLEMENTED。 -- ChannelType.CUSTOM 为共享枚举值(多个自定义渠道共用),通过 config 含 - ``ilink_bot_id`` 键区分 iLink 账户与其他 CUSTOM 渠道账户。 依赖方向:仅 import ``yuxi.channels.contract.*`` 与同插件内部模块 (``ilink_client``),不污染框架层。 @@ -35,9 +32,6 @@ from yuxi.channels.contract.ports.driven.persistence_port import PersistencePort from ..ilink_client import ILinkClient -# iLink 账户 config 中必含的键,用于从 CUSTOM 类型账户中筛选 iLink 账户 -_ILINK_CONFIG_KEY = "ilink_bot_id" - class WeChatILinkProbeableAdapter: """微信 iLink 渠道 API 主动探测适配器。 @@ -64,11 +58,10 @@ class WeChatILinkProbeableAdapter: async def probe(self) -> AdapterProbeOutcome: """主动探测渠道 API 可用性。 - 通过 ``PersistencePort`` 列出 CUSTOM 类型账户,按 config 含 - ``ilink_bot_id`` 键筛选出 iLink 账户,取首个账户的 ``account_id`` - 调用 ``ILinkClient.getconfig``(POST /ilink/bot/getconfig)作为 - 探活请求。getconfig 基于 ``CachePort`` 中的 bot_token 鉴权,验证 - 渠道 API 连通性与凭据有效性。延迟由 ``time.monotonic()`` 测量。 + 通过 ``PersistencePort`` 列出 wechat_ilink 类型账户,取首个账户的 + ``account_id`` 调用 ``ILinkClient.getconfig``(POST /ilink/bot/getconfig) + 作为探活请求。getconfig 基于 ``CachePort`` 中的 bot_token 鉴权, + 验证渠道 API 连通性与凭据有效性。延迟由 ``time.monotonic()`` 测量。 @pre: 无。 @post: 返回 ``AdapterProbeOutcome``,含 ``is_available`` / @@ -80,17 +73,16 @@ class WeChatILinkProbeableAdapter: start = time.monotonic() try: accounts = await self._persistence.listChannelAccounts( - ChannelType.CUSTOM, - limit=100, + ChannelType("wechat_ilink"), + limit=1, ) - ilink_accounts = [a for a in accounts if _ILINK_CONFIG_KEY in a.config] - if not ilink_accounts: + if not accounts: return AdapterProbeOutcome( is_available=False, latency_ms=int((time.monotonic() - start) * 1000), reason="no wechat_ilink account configured", ) - account_id = ilink_accounts[0].account_id + account_id = accounts[0].account_id await self._client.getconfig(account_id) except Exception as exc: await self._logger.warn( diff --git a/backend/package/yuxi/channels/plugins/wechat_ilink/adapters/puller_adapter.py b/backend/package/yuxi/channels/plugins/wechat_ilink/adapters/puller_adapter.py index c8986d01..669441b1 100644 --- a/backend/package/yuxi/channels/plugins/wechat_ilink/adapters/puller_adapter.py +++ b/backend/package/yuxi/channels/plugins/wechat_ilink/adapters/puller_adapter.py @@ -10,12 +10,17 @@ iLink ``/ilink/bot/getupdates`` 接口拉取新消息。游标由框架管理, from __future__ import annotations +from uuid import uuid4 + from yuxi.channels.contract.dtos.transport import PollingConfig, PollResult from yuxi.channels.contract.ports.driven.logger_port import LoggerPort from ..error_translator import translate_poll_error from ..ilink_client import ILinkClient +# 配置默认值(与 manifest.json long_poll_timeout_ms 一致,ConfigPort 未命中时回退) +_DEFAULT_LONG_POLL_TIMEOUT_MS = 35000 + class WeChatILinkPullerAdapter: """微信 iLink 长轮询适配器。 @@ -52,17 +57,22 @@ class WeChatILinkPullerAdapter: Returns: ``PollResult``:成功时含消息与新游标;失败时 ``error`` 非空。 + + @consistency: 无状态(stateless),游标由框架管理。 + @idempotent: True — 读操作,重复调用安全。 """ buf = cursor or "" + trace_id = uuid4().hex try: resp = await self._client.get_updates(account_id, buf) except Exception as exc: - error = translate_poll_error(exc) + error = translate_poll_error(exc, trace_id=trace_id) await self._logger.warn( "ilink poll failed", account_id=account_id, error=error.message, category=error.category, + trace_id=trace_id, ) return PollResult(messages=(), next_cursor=None, error=error) @@ -71,14 +81,20 @@ class WeChatILinkPullerAdapter: return PollResult(messages=messages, next_cursor=next_cursor, error=None) async def getPollingConfig(self) -> PollingConfig: - """返回长轮询配置。 + """返回长轮询配置(从 ConfigPort 读取,支持热更新)。 - long_poll_timeout_ms=35000(服务端 hang 35s) + long_poll_timeout_ms 由 ``long_poll_timeout_ms`` 配置项控制(默认 35000) poll_interval_ms=0(长轮询模式,无间隔) max_batch_size=100(单次最大拉取数) """ + timeout_ms = int( + await self._client.get_channel_config( + "long_poll_timeout_ms", + _DEFAULT_LONG_POLL_TIMEOUT_MS, + ) + ) return PollingConfig( - long_poll_timeout_ms=35000, + long_poll_timeout_ms=timeout_ms, poll_interval_ms=0, max_batch_size=100, ) diff --git a/backend/package/yuxi/channels/plugins/wechat_ilink/adapters/session_adapter.py b/backend/package/yuxi/channels/plugins/wechat_ilink/adapters/session_adapter.py index 4e6df712..080bdfa6 100644 --- a/backend/package/yuxi/channels/plugins/wechat_ilink/adapters/session_adapter.py +++ b/backend/package/yuxi/channels/plugins/wechat_ilink/adapters/session_adapter.py @@ -42,7 +42,7 @@ class WeChatILinkSessionAdapter: raise ValidationError(field="to_user_id", message="to_user_id_missing") return SessionInfo( - channel_type=ChannelType.CUSTOM, + channel_type=ChannelType("wechat_ilink"), account_id=account_id, peer_id=from_user_id, chat_type="p2p", diff --git a/backend/package/yuxi/channels/plugins/wechat_ilink/adapters/whitelist_adapter.py b/backend/package/yuxi/channels/plugins/wechat_ilink/adapters/whitelist_adapter.py index 7a2f7ebf..56ac38d6 100644 --- a/backend/package/yuxi/channels/plugins/wechat_ilink/adapters/whitelist_adapter.py +++ b/backend/package/yuxi/channels/plugins/wechat_ilink/adapters/whitelist_adapter.py @@ -116,6 +116,8 @@ class WeChatILinkWhitelistAdapter: @pre: entry 不为 None,且 ``entry.peer_id`` 不为空。 @post: 返回 True 表示添加成功,False 表示已存在。 @failure: 当依赖服务不可用时抛出 DependencyError。 + @consistency: 最终一致(eventual),写入 CachePort 后生效。 + @idempotent: True — 重复添加同 ``peer_id`` 返回 False,不产生副作用。 """ try: entries = await self._load_entries(entry.peer_type) @@ -144,6 +146,8 @@ class WeChatILinkWhitelistAdapter: @pre: peer_id 不为空,policy_type 不为 None。 @post: 返回 True 表示移除成功,False 表示不存在。 @failure: 当依赖服务不可用时抛出 DependencyError。 + @consistency: 最终一致(eventual),写入 CachePort 后生效。 + @idempotent: True — 重复移除不存在的条目返回 False,不产生副作用。 """ try: entries = await self._load_entries(policy_type) @@ -162,14 +166,18 @@ class WeChatILinkWhitelistAdapter: return True async def updateEntry(self, entry: WhitelistEntry) -> bool: - """修改白名单条目(直接覆盖语义)。 + """修改白名单条目(PATCH 语义:None 表示保留原值)。 @pre: ``entry.peer_id`` 不为空;``entry.peer_type`` 不为 None。 @post: True=修改成功,False=条目不存在。 @failure: 当依赖服务不可用时抛出 DependencyError。 - handler 已完成 PATCH 三态语义解析,``entry`` 各字段为最终值,直接 - 覆盖即可。仅 ``added_by`` 传入 None 时保留原值(记录条目创建者)。 + ``entry`` 各字段为 None 时保留原值(部分更新语义),非 None 时 + 覆盖。``peer_name`` 为空字符串时也视为覆盖(允许清空显示名)。 + ``added_by`` 始终保留原值(记录条目创建者,不可被覆盖)。 + + @consistency: 最终一致(eventual),写入 CachePort 后生效。 + @idempotent: True — PATCH 语义,重复调用相同 entry 结果一致。 """ try: entries = await self._load_entries(entry.peer_type) @@ -182,12 +190,12 @@ class WeChatILinkWhitelistAdapter: break if original is None: return False - # 直接覆盖各字段,仅 added_by 保留原值 + # PATCH 语义:None 保留原值,非 None 覆盖;added_by 始终保留原值 entries[idx] = replace( original, - peer_name=entry.peer_name, - reason=entry.reason, - expires_at=entry.expires_at, + peer_name=entry.peer_name if entry.peer_name is not None else original.peer_name, + reason=entry.reason if entry.reason is not None else original.reason, + expires_at=entry.expires_at if entry.expires_at is not None else original.expires_at, added_by=original.added_by, ) await self._save_entries(entry.peer_type, entries) diff --git a/backend/package/yuxi/channels/plugins/wechat_ilink/crypto.py b/backend/package/yuxi/channels/plugins/wechat_ilink/crypto.py index 25849256..466b1dc0 100644 --- a/backend/package/yuxi/channels/plugins/wechat_ilink/crypto.py +++ b/backend/package/yuxi/channels/plugins/wechat_ilink/crypto.py @@ -3,7 +3,16 @@ 提供 AES-128-ECB 加解密(PKCS7 填充)、三种 AES 密钥编码格式归一化、 X-WECHAT-UIN 防重放头生成与上传密文大小计算。 -仅依赖标准库 + cryptography 库,不 import 框架层。 +安全说明: +- AES-128-**ECB** 模式为 iLink CDN 协议强制要求(上传密钥由服务端 + ``getuploadurl`` 返回,下载密钥由消息体提供,客户端无法单方面改 GCM/CBC)。 + ECB 对相同明文块产生相同密文块,存在模式泄露风险;此处通过每次上传 + 生成一次性随机密钥(``generate_random_aes_key``)缓解,且加密对象为 + 高熵文件内容(图片/视频),风险有限。**禁止** 将本模块用于通用数据加密。 +- ``generate_x_wechat_uin`` 仅 4 字节(32-bit)熵,为 iLink 协议头格式约定, + 不应用于高安全场景的防重放。 + +仅依赖标准库 + cryptography 库 + contract.errors,不 import 框架层。 """ from __future__ import annotations @@ -16,6 +25,8 @@ import struct from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes from cryptography.hazmat.primitives.padding import PKCS7 +from yuxi.channels.contract.errors import ValidationError + # 32 个十六进制字符的正则,用于识别 Format C 与 Format B 的内层 hex 字符串 _HEX32_PATTERN = re.compile(r"[0-9a-fA-F]{32}") @@ -39,7 +50,7 @@ def normalize_aes_key(aes_key: str) -> bytes: 长度恒为 16 的原始密钥字节。 Raises: - ValueError: 格式无法识别时抛出 ``invalid_aes_key_format: {aes_key}``; + ValidationError: 格式无法识别时抛出 ``invalid_aes_key_format: {aes_key}``; 最终长度不为 16 字节时抛出 ``aes_key_must_be_16_bytes``。 """ # Format C: 32 个十六进制字符,直接 hex 解码 @@ -50,7 +61,10 @@ def normalize_aes_key(aes_key: str) -> bytes: try: decoded = base64.b64decode(aes_key, validate=True) except ValueError: - raise ValueError(f"invalid_aes_key_format: {aes_key}") + raise ValidationError( + field="aes_key", + message=f"invalid_aes_key_format: {aes_key}", + ) from None if len(decoded) == 16: # Format A: base64 解码后正好 16 字节 @@ -60,15 +74,27 @@ def normalize_aes_key(aes_key: str) -> bytes: try: hex_str = decoded.decode("ascii") except UnicodeDecodeError: - raise ValueError(f"invalid_aes_key_format: {aes_key}") from None + raise ValidationError( + field="aes_key", + message=f"invalid_aes_key_format: {aes_key}", + ) from None if not _HEX32_PATTERN.fullmatch(hex_str): - raise ValueError(f"invalid_aes_key_format: {aes_key}") + raise ValidationError( + field="aes_key", + message=f"invalid_aes_key_format: {aes_key}", + ) key = bytes.fromhex(hex_str) else: - raise ValueError(f"invalid_aes_key_format: {aes_key}") + raise ValidationError( + field="aes_key", + message=f"invalid_aes_key_format: {aes_key}", + ) if len(key) != 16: - raise ValueError("aes_key_must_be_16_bytes") + raise ValidationError( + field="aes_key", + message="aes_key_must_be_16_bytes", + ) return key diff --git a/backend/package/yuxi/channels/plugins/wechat_ilink/entry.py b/backend/package/yuxi/channels/plugins/wechat_ilink/entry.py index 9b2bb3f7..efdf9acf 100644 --- a/backend/package/yuxi/channels/plugins/wechat_ilink/entry.py +++ b/backend/package/yuxi/channels/plugins/wechat_ilink/entry.py @@ -109,12 +109,17 @@ def channel_entry(host: PluginHost) -> PluginManifest: cache_port = host.getCachePort() persistence_port = host.getPersistencePort() - # 2. 实例化生命周期处理器与 ILinkClient - # handler 需 client 引用以注入连接池;先实例化 handler(client=None), - # 再实例化 client,最后通过 attach_client 建立引用,解决循环依赖。 - handler = WeChatILinkLifecycleHandler(config_port, logger_port, cache_port) + # 2. 实例化 ILinkClient 与 LifecycleHandler + # 依赖关系为单向:handler 在 onInit 调 client.attach_http_client 注入连接池, + # client 不引用 handler。先创建 client,再创建 handler 并通过构造器注入。 client = ILinkClient(config_port, cache_port, logger_port) - handler.attach_client(client) + handler = WeChatILinkLifecycleHandler( + config_port, + logger_port, + cache_port, + _build_config_schema(), + client, + ) # 3. 实例化并注册 11 个适配器 # 8 个适配器接收 (client, logger_port);probeable 接收 @@ -143,7 +148,7 @@ def channel_entry(host: PluginHost) -> PluginManifest: id="com.yuxi.channels.wechat_ilink", name="微信iLink", version="1.0.0", - channel_type=ChannelType.CUSTOM, + channel_type=ChannelType("wechat_ilink"), provides=("wechat_ilink",), entry_module="yuxi.channels.plugins.wechat_ilink.entry", capabilities=ChannelCapabilities( @@ -165,7 +170,7 @@ def channel_entry(host: PluginHost) -> PluginManifest: directory=False, doctor=True, whitelist=True, - wizard=False, + wizard=True, tools=False, status=False, probeable=True, diff --git a/backend/package/yuxi/channels/plugins/wechat_ilink/error_translator.py b/backend/package/yuxi/channels/plugins/wechat_ilink/error_translator.py index 30a48ddc..6704da48 100644 --- a/backend/package/yuxi/channels/plugins/wechat_ilink/error_translator.py +++ b/backend/package/yuxi/channels/plugins/wechat_ilink/error_translator.py @@ -31,12 +31,27 @@ from yuxi.channels.contract.errors.transport import TransportError _ILINK_SESSION_EXPIRED_CODE = -14 # 会话过期(bot_token 失效) _ILINK_PARAM_ERROR_RET = -2 # 参数错误 -# 限流默认重试等待(毫秒) +# 限流默认重试等待(毫秒),服务端未返回 Retry-After 时回退 _RATE_LIMIT_RETRY_MS = 1000 -# 默认超时阈值(毫秒) +# 默认超时阈值(毫秒),实际超时因请求类型不同(普通 30s / 长轮询 40s) _DEFAULT_TIMEOUT_MS = 30000 +def _parse_retry_after_ms(exc: httpx.HTTPStatusError) -> int: + """从 429 响应头解析 Retry-After(秒),转毫秒。 + + 支持秒数格式(如 ``30``)。HTTP 日期格式不常用,回退到默认值。 + 解析失败或无头时回退到 ``_RATE_LIMIT_RETRY_MS``。 + """ + retry_after = exc.response.headers.get("Retry-After") + if retry_after is None: + return _RATE_LIMIT_RETRY_MS + try: + return int(retry_after) * 1000 + except (ValueError, TypeError): + return _RATE_LIMIT_RETRY_MS + + def _translate_contract_error_to_transport( exc: Error, trace_id: str, @@ -116,22 +131,11 @@ def translate_poll_error(exc: Exception, *, trace_id: str = "") -> TransportErro if isinstance(exc, Error): return _translate_contract_error_to_transport(exc, trace_id) - # iLink 响应体字典:按业务错误码映射分类 + # iLink 响应体字典:委托 check_ilink_error 统一检测(覆盖 errcode/ret/base_resp.ret) if isinstance(exc, dict): - if exc.get("errcode") == _ILINK_SESSION_EXPIRED_CODE: - return TransportError( - category="auth_expired", - message="ilink_session_expired", - raw_error=str(exc), - trace_id=trace_id, - ) - if exc.get("ret") == _ILINK_PARAM_ERROR_RET: - return TransportError( - category="permanent", - message="ilink_param_error", - raw_error=str(exc), - trace_id=trace_id, - ) + err = check_ilink_error(exc, trace_id=trace_id) + if err is not None: + return _translate_contract_error_to_transport(err, trace_id) return TransportError( category="permanent", message="ilink_response_error", @@ -146,7 +150,7 @@ def translate_poll_error(exc: Exception, *, trace_id: str = "") -> TransportErro return TransportError( category="rate_limited", message="ilink_rate_limited", - retry_after_ms=_RATE_LIMIT_RETRY_MS, + retry_after_ms=_parse_retry_after_ms(exc), raw_error=str(exc), trace_id=trace_id, ) @@ -218,20 +222,12 @@ def translate_call_error(exc: Exception, *, trace_id: str = "") -> None: if isinstance(exc, Error): raise exc - # iLink 响应体字典:按业务错误码抛出对应错误 + # iLink 响应体字典:委托 check_ilink_error 统一检测(覆盖 errcode/ret/base_resp.ret) # 字典非异常对象,无法用 ``from exc`` 链接,使用 ``from None`` 显式抑制上下文 if isinstance(exc, dict): - if exc.get("errcode") == _ILINK_SESSION_EXPIRED_CODE: - raise AuthError( - reason="session_expired", - trace_id=trace_id, - ) from None - if exc.get("ret") == _ILINK_PARAM_ERROR_RET: - raise ValidationError( - field="params", - message="ilink_param_error", - trace_id=trace_id, - ) from None + err = check_ilink_error(exc, trace_id=trace_id) + if err is not None: + raise err from None raise DependencyError( dep="ilink_api", cause=Exception(f"ilink_response_error: {exc}"), @@ -244,7 +240,7 @@ def translate_call_error(exc: Exception, *, trace_id: str = "") -> None: if status_code == 429: raise RateLimitError( resource="ilink_api", - retry_after_ms=_RATE_LIMIT_RETRY_MS, + retry_after_ms=_parse_retry_after_ms(exc), trace_id=trace_id, ) from exc if status_code >= 500: diff --git a/backend/package/yuxi/channels/plugins/wechat_ilink/ilink_client.py b/backend/package/yuxi/channels/plugins/wechat_ilink/ilink_client.py index c177ed16..48768f61 100644 --- a/backend/package/yuxi/channels/plugins/wechat_ilink/ilink_client.py +++ b/backend/package/yuxi/channels/plugins/wechat_ilink/ilink_client.py @@ -16,7 +16,9 @@ error_translator 转换为契约层异常,不向外抛原生异常。 from __future__ import annotations +import asyncio from typing import Any +from uuid import uuid4 import httpx @@ -68,6 +70,50 @@ class ILinkClient: self._logger = logger_port # 由 lifecycle 注入的共享连接池;为 None 时降级为每请求独立 client self._http_client: httpx.AsyncClient | None = None + # 活跃门控:由 lifecycle 在 onStart/onPause/onStop 时切换。 + # _active=False 时 _execute_http 拒绝新请求,防止 stop 后仍发请求。 + self._active: bool = True + # 在途请求计数 + drain 事件:onStop/onUnload 等待在途请求完成。 + self._inflight: int = 0 + self._drain_event: asyncio.Event = asyncio.Event() + self._drain_event.set() # 初始无在途请求,事件已置位 + + def set_active(self, active: bool) -> None: + """切换活跃状态(由 lifecycle 调用)。 + + ``active=False`` 后,``_execute_http`` 拒绝新请求并抛 + ``DependencyError``,防止 stop/pause 后仍发出 HTTP 请求。 + """ + self._active = active + + async def drain(self, timeout: float = 30.0) -> bool: + """等待在途请求完成(由 lifecycle 在 onStop/onUnload 调用)。 + + Args: + timeout: 最长等待秒数(默认 30s,对齐 Protocol 契约)。 + + Returns: + ``True`` 表示在途请求已全部完成;``False`` 表示超时仍有在途请求。 + """ + if self._inflight == 0: + return True + await self._logger.info( + "iLink client draining in-flight requests", + inflight=self._inflight, + timeout=timeout, + ) + try: + await asyncio.wait_for(self._drain_event.wait(), timeout=timeout) + return True + # 捕获 asyncio.wait_for 超时(Python 3.11+ asyncio.TimeoutError 即内置 TimeoutError), + # 遵循项目约定:drain 降级为返回 False,不向框架抛出原生异常。 + except TimeoutError: + await self._logger.warn( + "iLink client drain timed out, in-flight requests may be interrupted", + inflight=self._inflight, + timeout=timeout, + ) + return False def attach_http_client(self, client: httpx.AsyncClient) -> None: """注入 lifecycle 管理的共享 httpx 连接池。 @@ -147,6 +193,10 @@ class ILinkClient: 连接池未注入时降级为每请求独立 client(兼容测试场景)。HTTP 异常 通过 ``error_translator.translate_call_error`` 转换为契约层异常。 + 生命周期门控:``_active=False`` 时拒绝新请求并抛 ``DependencyError``。 + 在途请求计数:进入时 +1,退出时 -1,归零时设置 ``_drain_event`` 供 + lifecycle 的 ``drain`` 等待。 + Args: method: HTTP 方法(POST / GET)。 url: 完整请求 URL。 @@ -160,9 +210,18 @@ class ILinkClient: Returns: ``httpx.Response``(已 ``raise_for_status``)。 """ + if not self._active: + raise DependencyError( + dep="ilink_api", + cause=Exception("client_inactive_after_stop"), + ) + # 生成请求级 trace_id,注入 error_translator 供异常追踪关联 + trace_id = uuid4().hex request_headers = dict(headers) if content is not None and content_type is not None: request_headers["Content-Type"] = content_type + self._inflight += 1 + self._drain_event.clear() try: if self._http_client is not None: resp = await self._http_client.request( @@ -191,9 +250,14 @@ class ILinkClient: exc_info=exc, method=method, url=url, + trace_id=trace_id, ) - error_translator.translate_call_error(exc) # raises + error_translator.translate_call_error(exc, trace_id=trace_id) # raises raise # 不可达,满足类型检查器 + finally: + self._inflight -= 1 + if self._inflight == 0: + self._drain_event.set() return resp @staticmethod @@ -209,14 +273,16 @@ class ILinkClient: - ``errcode`` 非 0 → ``DependencyError``(其他业务错误) - 无错误 → 返回响应体字典 """ + trace_id = uuid4().hex try: data = resp.json() except ValueError as exc: raise DependencyError( dep="ilink_api", cause=exc, + trace_id=trace_id, ) from exc - err = error_translator.check_ilink_error(data) + err = error_translator.check_ilink_error(data, trace_id=trace_id) if err is not None: raise err from None return data @@ -272,11 +338,14 @@ class ILinkClient: - account_id 非空时注入鉴权头 - GET {baseurl}{path}?{params} - base_url 非空时覆盖默认 baseurl(IDC 重定向场景) + - base_url 无 scheme 时补 ``https://``(IDC redirect_host 可能仅主机名) - HTTP 异常 + iLink 业务错误码通过 error_translator 转换 - 返回校验后的 JSON 响应体 """ if base_url is None: base_url = await self.get_channel_config("baseurl", _DEFAULT_BASEURL) + elif not base_url.startswith(("http://", "https://")): + base_url = f"https://{base_url}" url = f"{base_url}{path}" headers: dict[str, str] = {} if account_id: @@ -393,6 +462,10 @@ class ILinkClient: POST {upload_url}?encrypt_query_param={encrypt_query_param} Body: 原始密文字节(非 JSON) Headers: Content-Type: application/octet-stream + + Note: 上传参数名为 ``encrypt_query_param``(来自 getuploadurl 响应), + 下载端点使用 ``encrypted_query_param``(多 "ed"),为 iLink API + 上传/下载端点的参数名约定差异。 """ await self._execute_http( "POST", @@ -472,17 +545,16 @@ class ILinkClient: # ------------------------------------------------------------------ async def get_cursor(self, account_id: str) -> str: - """读取游标,未命中返回空字符串。""" + """读取游标,未命中返回空字符串。 + + 供 ``DoctorAdapter`` 健康检查使用:非空游标表示长轮询已建立。 + 游标写入由框架通过 ``PollResult.next_cursor`` 管理,适配器不持久化。 + """ key = f"wechat_ilink:cursor:{account_id}" cached = await self._cache.get(key) if cached.is_some(): return cached.unwrap() return "" - async def set_cursor(self, account_id: str, cursor: str) -> None: - """写入游标。""" - key = f"wechat_ilink:cursor:{account_id}" - await self._cache.set(key, cursor) - __all__ = ["ILinkClient"] diff --git a/backend/package/yuxi/channels/plugins/wechat_ilink/lifecycle.py b/backend/package/yuxi/channels/plugins/wechat_ilink/lifecycle.py index d33b0f46..80c2ed22 100644 --- a/backend/package/yuxi/channels/plugins/wechat_ilink/lifecycle.py +++ b/backend/package/yuxi/channels/plugins/wechat_ilink/lifecycle.py @@ -27,6 +27,7 @@ from typing import TYPE_CHECKING, Any import httpx +from yuxi.channels.contract.dtos.config import ConfigField from yuxi.channels.contract.errors import ConfigRestartRequiredError from yuxi.channels.contract.ports.driven.cache_port import CachePort from yuxi.channels.contract.ports.driven.config_port import ConfigPort @@ -35,14 +36,6 @@ from yuxi.channels.contract.ports.driven.logger_port import LoggerPort if TYPE_CHECKING: from .ilink_client import ILinkClient -# 不可热更新的配置键(manifest.json 中 hot_reloadable=false) -_RESTART_REQUIRED_KEYS: tuple[str, ...] = ( - "bot_token", - "ilink_bot_id", - "ilink_user_id", - "baseurl", -) - # httpx 连接池配置(对齐 manifest.json resource_quota: max_connections=10) _HTTP_TIMEOUT_SECONDS = 30.0 _HTTP_MAX_CONNECTIONS = 10 @@ -63,6 +56,7 @@ class WeChatILinkLifecycleHandler: config_port: ConfigPort, logger_port: LoggerPort, cache_port: CachePort, + config_schema: tuple[ConfigField, ...], client: ILinkClient | None = None, ) -> None: self._config = config_port @@ -72,15 +66,10 @@ class WeChatILinkLifecycleHandler: self._http_client: httpx.AsyncClient | None = None self._started: bool = False self._last_config: dict[str, Any] | None = None - - def attach_client(self, client: ILinkClient) -> None: - """注入 ``ILinkClient`` 引用,供 ``onInit`` 注入连接池。 - - 由 ``entry.channel_entry`` 在实例化 client 后调用(解决 client 与 - handler 的循环依赖:handler 需 client 注入连接池,client 需 handler - 创建的连接池)。 - """ - self._client = client + # 从 config_schema 派生不可热更新键(hot_reloadable=False 的字段 key) + self._restart_required_keys: tuple[str, ...] = tuple( + field.key for field in config_schema if not field.hot_reloadable + ) # ------------------------------------------------------------------ # LifecycleHookHandler Protocol 实现 @@ -100,35 +89,51 @@ class WeChatILinkLifecycleHandler: await self._logger.info("WeChat iLink plugin initialized") async def onStart(self) -> None: - """标记插件就绪。""" + """标记插件就绪:允许 ILinkClient 接受新请求。""" self._started = True + if self._client is not None: + self._client.set_active(True) await self._logger.info("WeChat iLink plugin started") async def onStop(self) -> None: - """停止插件:标记未就绪,等待在途请求完成。""" + """停止插件:拒绝新请求,等待在途请求完成(默认 30s 超时)。 + + 先 ``set_active(False)`` 阻止新请求进入 ``_execute_http``,再 + ``drain(30)`` 等待在途请求完成。超时后记录告警但仍继续关闭流程。 + """ await self._logger.info( "WeChat iLink plugin stopping, waiting for in-flight requests", ) self._started = False + if self._client is not None: + self._client.set_active(False) + await self._client.drain(timeout=30.0) async def onPause(self) -> None: """暂停插件:停止接收新请求。""" self._started = False + if self._client is not None: + self._client.set_active(False) await self._logger.info("WeChat iLink plugin paused") async def onResume(self) -> None: """恢复插件:重新接收请求。""" self._started = True + if self._client is not None: + self._client.set_active(True) await self._logger.info("WeChat iLink plugin resumed") async def onUnload(self) -> None: - """释放所有资源:解除 client 引用后关闭 httpx 连接池。 + """释放所有资源:drain 在途请求后关闭 httpx 连接池。 长轮询连接由 ``StreamWorker`` 在宿主关停时首先停止, - 本方法不再处理拉取句柄清理。 + 本方法先 drain 在途请求(默认 30s 超时),再解除 client 引用并 + 关闭连接池。 """ - # 先解除 client 对连接池的引用,避免关闭后悬挂调用 + # 先 drain 在途请求,避免 aclose 打断正在进行的请求 if self._client is not None: + self._client.set_active(False) + await self._client.drain(timeout=30.0) self._client.detach_http_client() if self._http_client is not None: try: @@ -146,8 +151,8 @@ class WeChatILinkLifecycleHandler: async def onReconfigure(self, config: dict[str, Any]) -> None: """配置热更新:校验不可热更新字段,接受则缓存为新基线。 - - ``bot_token`` / ``ilink_bot_id`` / ``ilink_user_id`` / ``baseurl`` - 变更抛 ``ConfigRestartRequiredError``(FR-37)。 + - ``_restart_required_keys``(从 config_schema 派生,``hot_reloadable=False`` + 的字段)变更抛 ``ConfigRestartRequiredError``(FR-37)。 - 首次调用(``_last_config`` 为 None)仅建立基线,不做对比。 - 校验通过后更新 ``_last_config``;校验失败保持原基线,由宿主回滚。 """ @@ -158,7 +163,7 @@ class WeChatILinkLifecycleHandler: ) return - for key in _RESTART_REQUIRED_KEYS: + for key in self._restart_required_keys: new_value = config.get(key) old_value = self._last_config.get(key) if new_value != old_value: diff --git a/backend/package/yuxi/channels/plugins/wechat_ilink/manifest.json b/backend/package/yuxi/channels/plugins/wechat_ilink/manifest.json index 8a335a35..6b086d96 100644 --- a/backend/package/yuxi/channels/plugins/wechat_ilink/manifest.json +++ b/backend/package/yuxi/channels/plugins/wechat_ilink/manifest.json @@ -2,7 +2,7 @@ "id": "com.yuxi.channels.wechat_ilink", "name": "微信iLink", "version": "1.0.0", - "channel_type": "custom", + "channel_type": "wechat_ilink", "entry_module": "yuxi.channels.plugins.wechat_ilink.entry", "provides": ["wechat_ilink"], "lifecycle": ["init", "start", "stop", "pause", "resume", "unload"], @@ -44,7 +44,7 @@ "directory": false, "doctor": true, "whitelist": true, - "wizard": false, + "wizard": true, "tools": false, "status": false, "probeable": true,