diff --git a/backend/package/yuxi/external_systems/exceptions.py b/backend/package/yuxi/external_systems/exceptions.py index 497561c1..d5e3dbf8 100644 --- a/backend/package/yuxi/external_systems/exceptions.py +++ b/backend/package/yuxi/external_systems/exceptions.py @@ -33,7 +33,8 @@ adapters),放在根目录便于统一导入路径。 ├── SecretResolutionError (400) # 密钥解析失败 ├── AuthError (403) # 认证失败 ├── AccessDeniedError (403) # 访问规则拒绝 - └── WebhookError (400) # Webhook 验签/处理失败 + ├── WebhookError (400) # Webhook 验签/处理失败 + └── CapabilityNotSupportedError (501) # 适配器未实现可选能力 """ from __future__ import annotations @@ -448,3 +449,46 @@ class WebhookError(FrameworkError): status_code = 400 error_code = "WEBHOOK_ERROR" + + +class CapabilityNotSupportedError(FrameworkError): + """适配器未实现某项可选能力。 + + 当适配器存在但不实现某项可选能力(如 ``validate_config``)时抛出, + 由 Router 捕获 Python 内置 ``NotImplementedError`` 后翻译为本异常, + 交由 ``unified_error_handler`` 统一映射为 HTTP 501,避免原生异常 + 被 ``unhandled_exception_handler`` 兜底为 500。 + + 与 ``IntegrationOperationNotRegisteredError`` 的区别:本异常表示 + 适配器实例不支持该能力方法;后者表示厂商集成的操作未注册。 + """ + + status_code = 501 + error_code = "CAPABILITY_NOT_SUPPORTED" + + def __init__( + self, + capability: str, + *, + adapter_type: str | None = None, + details: dict[str, Any] | None = None, + trace_id: str | None = None, + ) -> None: + """初始化能力未支持异常。 + + 参数: + capability: 未实现的能力名称(如 ``validate_config``)。 + adapter_type: 适配器类型,写入 ``details`` 供客户端参考。 + details: 业务字段字典,与上述参数合并。 + trace_id: 调用链路追踪 ID,用于跨链路关联。 + """ + merged: dict[str, Any] = dict(details or {}) + if adapter_type is not None: + merged["adapter_type"] = adapter_type + merged["capability"] = capability + adapter_desc = f" {adapter_type}" if adapter_type else "" + super().__init__( + f"适配器{adapter_desc}未实现能力: {capability}", + details=merged, + trace_id=trace_id, + ) diff --git a/backend/package/yuxi/external_systems/integrations/registry.py b/backend/package/yuxi/external_systems/integrations/registry.py index ada54922..2eb0c972 100644 --- a/backend/package/yuxi/external_systems/integrations/registry.py +++ b/backend/package/yuxi/external_systems/integrations/registry.py @@ -13,7 +13,7 @@ from __future__ import annotations -from yuxi.external_systems.exceptions import DomainValidationError +from yuxi.external_systems.exceptions import DomainValidationError, EntityNotFoundError from yuxi.external_systems.integrations.schemas import IntegrationMetadata @@ -84,9 +84,7 @@ class IntegrationRegistry: 分页基于 filter_integrations 的全量结果切片,全量总数请用 count_integrations 获取(list 返回的 len(items) 仅为当前页条数)。 """ - filtered = cls.filter_integrations( - adapter_type=adapter_type, tags=tags, keyword=keyword - ) + filtered = cls.filter_integrations(adapter_type=adapter_type, tags=tags, keyword=keyword) if offset < 0: offset = 0 if limit < 0: @@ -106,17 +104,24 @@ class IntegrationRegistry: 与 list_integrations 共用 filter_integrations,保证 total 与 分页结果一致。供 Router 层返回真实全量 total,避免前端分页页数错误。 """ - return len( - cls.filter_integrations( - adapter_type=adapter_type, tags=tags, keyword=keyword - ) - ) + return len(cls.filter_integrations(adapter_type=adapter_type, tags=tags, keyword=keyword)) @classmethod def get_integration(cls, key: str) -> IntegrationMetadata | None: """按 key 获取厂商集成元数据,未找到返回 None。""" return cls._integrations.get(key) + @classmethod + def get_integration_or_raise(cls, key: str) -> IntegrationMetadata: + """按 key 获取厂商集成元数据,未找到抛 ``EntityNotFoundError`` (404)。 + + 供 Router 的详情类端点使用,避免每个端点重复 None 判断与抛异常逻辑。 + """ + metadata = cls._integrations.get(key) + if metadata is None: + raise EntityNotFoundError(f"厂商集成 {key} 不存在") + return metadata + @classmethod def list_adapter_types(cls) -> list[str]: """列出所有已注册厂商集成涉及的适配器类型(去重)。""" diff --git a/backend/package/yuxi/external_systems/use_cases/dto/tool.py b/backend/package/yuxi/external_systems/use_cases/dto/tool.py index 2afe6967..ac9289ea 100644 --- a/backend/package/yuxi/external_systems/use_cases/dto/tool.py +++ b/backend/package/yuxi/external_systems/use_cases/dto/tool.py @@ -11,6 +11,7 @@ from __future__ import annotations +from datetime import datetime from typing import TYPE_CHECKING, Any from pydantic import BaseModel, ConfigDict, Field @@ -506,8 +507,8 @@ class GetToolExecutionTrendInput(BaseModel): model_config = ConfigDict(frozen=True) tool_id: int - start_time: str | None = None - end_time: str | None = None + start_time: datetime | None = None + end_time: datetime | None = None interval: str = "day" env_key: str | None = None diff --git a/backend/package/yuxi/external_systems/use_cases/dto/webhook.py b/backend/package/yuxi/external_systems/use_cases/dto/webhook.py index af5a794b..87e52a87 100644 --- a/backend/package/yuxi/external_systems/use_cases/dto/webhook.py +++ b/backend/package/yuxi/external_systems/use_cases/dto/webhook.py @@ -7,6 +7,7 @@ dataclass 对齐。 from __future__ import annotations +from datetime import datetime from typing import Any from pydantic import BaseModel, ConfigDict, Field @@ -124,8 +125,8 @@ class ListWebhookEventsInput(BaseModel): triggered_execution_id: str | None = None signature_verified: bool | None = None source_ip: str | None = None - start: Any | None = None - end: Any | None = None + start: datetime | None = None + end: datetime | None = None class GetEventInput(BaseModel): diff --git a/backend/package/yuxi/external_systems/use_cases/services/tool_service.py b/backend/package/yuxi/external_systems/use_cases/services/tool_service.py index 423dd160..0548584b 100644 --- a/backend/package/yuxi/external_systems/use_cases/services/tool_service.py +++ b/backend/package/yuxi/external_systems/use_cases/services/tool_service.py @@ -25,7 +25,6 @@ tool_router 扩展端点(版本对比 / 版本回滚 / 参数模板 / 批量 from __future__ import annotations import uuid -from datetime import datetime from typing import TYPE_CHECKING, Any from yuxi.external_systems.core.contracts import ( @@ -384,11 +383,7 @@ class ToolService(ToolServicePort): adapter = self._registry.get_adapter(tool.adapter_type) # 系统级工具预解析 context 供 adapter 构造 schema(如 gRPC asset 加载 proto); # 工具级工具 context 为 None(契约允许)。 - context = ( - await self._context_builder.build(tool, env) - if tool.system_id is not None - else None - ) + context = await self._context_builder.build(tool, env) if tool.system_id is not None else None schema_tool = await adapter.build_tool(tool, context) env_dict = env or {} @@ -994,7 +989,8 @@ class ToolService(ToolServicePort): async with self._uow: # 2. 回滚前创建当前配置的新版本快照(记录回滚前的状态) new_version_id = await self._record_version_before_update( - tool, input_dto.updated_by, + tool, + input_dto.updated_by, ) # 3. 将目标版本明文 snapshot 写回 ext_tool_configs @@ -1198,12 +1194,12 @@ class ToolService(ToolServicePort): details={"interval": input_dto.interval}, ) - start = self._parse_iso_datetime(input_dto.start_time) - end = self._parse_iso_datetime(input_dto.end_time) + start = input_dto.start_time + end = input_dto.end_time if start is not None and end is not None and start > end: raise DomainValidationError( "start_time 不能晚于 end_time", - details={"start_time": input_dto.start_time, "end_time": input_dto.end_time}, + details={"start_time": start.isoformat(), "end_time": end.isoformat()}, ) rows = await self._repos.execution.aggregate_trend_by_tool( @@ -1269,21 +1265,3 @@ class ToolService(ToolServicePort): items = [to_asset_output(a) for a in assets] return GetToolAssetsOutput(items=items) - - # ========== 内部辅助(扩展端点) ========== - - @staticmethod - def _parse_iso_datetime(value: str | None) -> datetime | None: - """解析 ISO 8601 时间字符串,返回 ``datetime`` 或 ``None``。 - - 解析失败时抛 ``DomainValidationError``,不静默回退。 - """ - if value is None: - return None - try: - return datetime.fromisoformat(value.replace("Z", "+00:00")) - except ValueError as exc: - raise DomainValidationError( - f"时间格式非法: {value}(需 ISO 8601 格式)", - details={"value": value}, - ) from exc