refactor(external_systems): 优化时间参数处理与新增异常能力

1. 将工具、webhook的时间DTO字段从字符串改为datetime类型
2. 移除工具服务中的手动ISO时间解析逻辑
3. 新增CapabilityNotSupportedError异常与get_integration_or_raise方法
4. 格式化代码行内表达式简化写法
This commit is contained in:
Kris 2026-07-03 19:18:32 +08:00
parent 00092c818e
commit 1965b9f742
5 changed files with 71 additions and 42 deletions

View File

@ -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,
)

View File

@ -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]:
"""列出所有已注册厂商集成涉及的适配器类型(去重)。"""

View File

@ -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

View File

@ -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):

View File

@ -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