From 1003b842e16a0a53eb427f4f8d830331260b1880 Mon Sep 17 00:00:00 2001 From: Kris <2893855659@qq.com> Date: Wed, 24 Jun 2026 21:51:44 +0800 Subject: [PATCH] =?UTF-8?q?refactor(exceptions):=20=E7=BB=9F=E4=B8=80?= =?UTF-8?q?=E5=BC=82=E5=B8=B8=E4=BD=93=E7=B3=BB=E5=B9=B6=E6=9B=BF=E6=8D=A2?= =?UTF-8?q?=E9=9B=B6=E6=95=A3=E5=BC=82=E5=B8=B8=E6=8A=9B=E5=87=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. 新增FrameworkError作为框架技术异常基类,统一处理非业务校验类错误 2. 为所有异常类添加error_code和trace_id支持,完善异常序列化能力 3. 将多处原生异常、RuntimeError替换为标准领域/框架异常 4. 优化WSDL解析逻辑,添加详细的警告日志便于排查问题 --- .../yuxi/external_systems/exceptions.py | 111 +++++++++++++++++- .../adapters/http/request_builders/form.py | 3 +- .../adapters/soap/generators/wsdl.py | 10 +- .../framework/executor/system_executor.py | 3 +- .../runtime/secret_rotation_scheduler.py | 8 +- .../framework/runtime/test_case_runner.py | 8 +- .../integrations/dynamics365/operations.py | 3 +- .../integrations/sap_s4hana/operations.py | 3 +- .../integrations/slack/channel_resolver.py | 10 +- .../use_cases/services/import_service.py | 4 +- .../use_cases/services/test_case_service.py | 5 +- .../services/test_regression_service.py | 3 +- 12 files changed, 146 insertions(+), 25 deletions(-) diff --git a/backend/package/yuxi/external_systems/exceptions.py b/backend/package/yuxi/external_systems/exceptions.py index 230315be..497561c1 100644 --- a/backend/package/yuxi/external_systems/exceptions.py +++ b/backend/package/yuxi/external_systems/exceptions.py @@ -46,22 +46,48 @@ class ExternalSystemError(Exception): 所有本上下文抛出的异常都应继承此类,最外层统一捕获后映射为 HTTP 响应。 ``status_code`` 作为异常到 HTTP 状态码的映射依据,子类按需覆盖。 + ``error_code`` 作为稳定错误码,子类以类变量覆盖,用于驱动协议响应与日志关联。 + ``trace_id`` 用于跨链路追踪关联。 """ status_code: int = 500 + error_code: str = "EXTERNAL_SYSTEM_ERROR" def __init__( self, message: str, *, details: dict[str, Any] | None = None, + trace_id: str | None = None, ) -> None: + """初始化异常实例。 + + 参数: + message: 人类可读错误信息。 + details: 业务字段字典,默认空字典。 + trace_id: 调用链路追踪 ID,用于跨链路关联。 + """ super().__init__(message) self.message = message self.details = details or {} + self.trace_id = trace_id + + def to_dict(self) -> dict[str, Any]: + """序列化为字典,便于跨层传递、日志记录与 API 响应。 + + 返回 ``error_code`` / ``message`` / ``trace_id`` 三个基础字段, + 并展开 ``details`` 中的业务字段。 + """ + return { + "error_code": self.error_code, + "message": self.message, + "trace_id": self.trace_id, + **self.details, + } def __str__(self) -> str: - return self.message + """返回 ``[error_code] message`` 格式的字符串表示。""" + return f"[{self.error_code}] {self.message}" # ─── 业务规则异常 ────────────────────────────────────────────────────────── @@ -75,24 +101,32 @@ class DomainValidationError(ExternalSystemError, ValueError): """ status_code = 400 + error_code = "VALIDATION_ERROR" class InvalidSlugError(DomainValidationError): """slug 非法或命中保留字。""" + error_code = "INVALID_SLUG" + class EnvironmentDisabledError(DomainValidationError): """禁用环境不能设为默认环境。""" + error_code = "ENVIRONMENT_DISABLED" + class ImportConflictError(DomainValidationError): """导入时发现 slug 冲突。""" + error_code = "IMPORT_CONFLICT" + class SsrfViolationError(DomainValidationError): """SSRF 安全规则违反。""" status_code = 403 + error_code = "SSRF_VIOLATION" class CircuitBreakerStateError(DomainValidationError): @@ -102,6 +136,8 @@ class CircuitBreakerStateError(DomainValidationError): 表示熔断器未处于打开或半开状态,无需也无法重置。 """ + error_code = "CIRCUIT_BREAKER_STATE_INVALID" + class EntityNotFoundError(ExternalSystemError): """领域实体不存在。 @@ -112,6 +148,7 @@ class EntityNotFoundError(ExternalSystemError): """ status_code = 404 + error_code = "NOT_FOUND" class ReferencedError(ExternalSystemError): @@ -127,12 +164,25 @@ class ReferencedError(ExternalSystemError): """ status_code = 409 + error_code = "REFERENCED" - def __init__(self, references: dict[str, dict[str, list[str]]]) -> None: + def __init__( + self, + references: dict[str, dict[str, list[str]]], + *, + trace_id: str | None = None, + ) -> None: + """初始化被引用无法删除异常。 + + 参数: + references: 被引用关系字典,结构示例见类 docstring。 + trace_id: 调用链路追踪 ID,用于跨链路关联。 + """ self.references = references super().__init__( f"以下外部系统仍被引用,无法删除: {references}", details={"references": references}, + trace_id=trace_id, ) @@ -145,6 +195,7 @@ class ConflictError(ExternalSystemError): """ status_code = 409 + error_code = "CONFLICT" # ─── 框架技术异常 ────────────────────────────────────────────────────────── @@ -158,6 +209,8 @@ class FrameworkError(ExternalSystemError): 而非"业务规则不允许"。 """ + error_code = "FRAMEWORK_ERROR" + class AdapterNotFoundError(FrameworkError): """找不到对应 ``adapter_type`` 的适配器。 @@ -166,12 +219,14 @@ class AdapterNotFoundError(FrameworkError): """ status_code = 404 + error_code = "ADAPTER_NOT_FOUND" class AuthPluginNotFoundError(FrameworkError): """找不到对应 ``auth_type`` 的认证插件。""" status_code = 404 + error_code = "AUTH_PLUGIN_NOT_FOUND" class IntegrationOperationNotRegisteredError(FrameworkError): @@ -186,6 +241,7 @@ class IntegrationOperationNotRegisteredError(FrameworkError): """ status_code = 400 + error_code = "OPERATION_NOT_REGISTERED" class ExecutionError(FrameworkError): @@ -196,6 +252,7 @@ class ExecutionError(FrameworkError): """ status_code = 502 + error_code = "EXECUTION_ERROR" class RateLimitExceededError(ExecutionError): @@ -210,6 +267,7 @@ class RateLimitExceededError(ExecutionError): """ status_code = 429 + error_code = "RATE_LIMIT_EXCEEDED" def __init__( self, @@ -220,7 +278,19 @@ class RateLimitExceededError(ExecutionError): system_id: int | str | None = None, env_key: str | None = None, details: dict[str, Any] | None = None, + trace_id: str | None = None, ) -> None: + """初始化限流超限异常。 + + 参数: + message: 人类可读错误信息。 + retry_after: 建议客户端等待秒数(令牌恢复时间)。 + limit_type: 限流类型,``qps`` 或 ``concurrency``。 + system_id: 限流维度系统 ID。 + env_key: 限流维度环境键。 + details: 业务字段字典,与上述参数合并。 + trace_id: 调用链路追踪 ID,用于跨链路关联。 + """ merged: dict[str, Any] = dict(details or {}) if retry_after is not None: merged["retry_after"] = retry_after @@ -230,7 +300,7 @@ class RateLimitExceededError(ExecutionError): merged["system_id"] = system_id if env_key is not None: merged["env_key"] = env_key - super().__init__(message, details=merged) + super().__init__(message, details=merged, trace_id=trace_id) class CircuitOpenError(ExecutionError): @@ -244,6 +314,7 @@ class CircuitOpenError(ExecutionError): """ status_code = 503 + error_code = "CIRCUIT_OPEN" def __init__( self, @@ -253,7 +324,18 @@ class CircuitOpenError(ExecutionError): system_id: int | str | None = None, env_key: str | None = None, details: dict[str, Any] | None = None, + trace_id: str | None = None, ) -> None: + """初始化熔断器打开异常。 + + 参数: + message: 人类可读错误信息。 + retry_after: 剩余冷却秒数(熔断器进入 half_open 的等待时间)。 + system_id: 熔断维度系统 ID。 + env_key: 熔断维度环境键。 + details: 业务字段字典,与上述参数合并。 + trace_id: 调用链路追踪 ID,用于跨链路关联。 + """ merged: dict[str, Any] = dict(details or {}) if retry_after is not None: merged["retry_after"] = retry_after @@ -261,7 +343,7 @@ class CircuitOpenError(ExecutionError): merged["system_id"] = system_id if env_key is not None: merged["env_key"] = env_key - super().__init__(message, details=merged) + super().__init__(message, details=merged, trace_id=trace_id) class SecretResolutionError(FrameworkError): @@ -272,6 +354,7 @@ class SecretResolutionError(FrameworkError): """ status_code = 400 + error_code = "SECRET_RESOLUTION_FAILED" class AuthError(FrameworkError): @@ -282,6 +365,7 @@ class AuthError(FrameworkError): """ status_code = 403 + error_code = "AUTH_ERROR" class QuotaExceededError(ExecutionError): @@ -300,6 +384,7 @@ class QuotaExceededError(ExecutionError): """ status_code = 429 + error_code = "QUOTA_EXCEEDED" def __init__( self, @@ -312,7 +397,21 @@ class QuotaExceededError(ExecutionError): system_id: int | str | None = None, env_key: str | None = None, details: dict[str, Any] | None = None, + trace_id: str | None = None, ) -> None: + """初始化配额耗尽异常。 + + 参数: + message: 人类可读错误信息。 + quota_key: 配额键。 + limit_value: 配额上限。 + used_value: 已用配额。 + remaining: 剩余配额(通常为 0)。 + system_id: 配额维度系统 ID。 + env_key: 配额维度环境键。 + details: 业务字段字典,与上述参数合并。 + trace_id: 调用链路追踪 ID,用于跨链路关联。 + """ merged: dict[str, Any] = dict(details or {}) if quota_key is not None: merged["quota_key"] = quota_key @@ -326,7 +425,7 @@ class QuotaExceededError(ExecutionError): merged["system_id"] = system_id if env_key is not None: merged["env_key"] = env_key - super().__init__(message, details=merged) + super().__init__(message, details=merged, trace_id=trace_id) class AccessDeniedError(FrameworkError): @@ -338,6 +437,7 @@ class AccessDeniedError(FrameworkError): """ status_code = 403 + error_code = "ACCESS_DENIED" class WebhookError(FrameworkError): @@ -347,3 +447,4 @@ class WebhookError(FrameworkError): """ status_code = 400 + error_code = "WEBHOOK_ERROR" diff --git a/backend/package/yuxi/external_systems/framework/adapters/http/request_builders/form.py b/backend/package/yuxi/external_systems/framework/adapters/http/request_builders/form.py index ac5569fd..9c9c1602 100644 --- a/backend/package/yuxi/external_systems/framework/adapters/http/request_builders/form.py +++ b/backend/package/yuxi/external_systems/framework/adapters/http/request_builders/form.py @@ -4,6 +4,7 @@ from __future__ import annotations from typing import Any +from yuxi.external_systems.exceptions import DomainValidationError from yuxi.external_systems.framework.adapters.http.models import ( RequestBuildState, ) @@ -34,7 +35,7 @@ class FormRequestBuilder(RequestBuilder): state.body = body_params or None if state.body is not None: if not isinstance(state.body, dict): - raise ValueError("form 请求体必须是字典") + raise DomainValidationError("form 请求体必须是字典") # 注意:httpx 发送 files 时会自动设置带 boundary 的 Content-Type, # 此处不手动设置,避免 boundary 缺失。 state.body = {k: (None, str(v)) for k, v in state.body.items()} diff --git a/backend/package/yuxi/external_systems/framework/adapters/soap/generators/wsdl.py b/backend/package/yuxi/external_systems/framework/adapters/soap/generators/wsdl.py index f6bdfbf1..e585aa46 100644 --- a/backend/package/yuxi/external_systems/framework/adapters/soap/generators/wsdl.py +++ b/backend/package/yuxi/external_systems/framework/adapters/soap/generators/wsdl.py @@ -246,7 +246,8 @@ def _extract_parameters( try: input_message = operation.input - except Exception: + except Exception as exc: + logger.warning(f"WSDL 解析失败,返回空列表: {exc}") return [] # 尝试从 body type 的 elements 提取 @@ -267,14 +268,15 @@ def _extract_parameters( if param is not None: parameters.append(param) return parameters - except Exception: - pass + except Exception as exc: + logger.warning(f"WSDL 解析失败,回退到签名解析: {exc}") # 兜底:从 signature 字符串提取顶层参数名 try: signature = str(input_message.signature()) return _parse_signature_parameters(signature) - except Exception: + except Exception as exc: + logger.warning(f"WSDL 解析失败,返回空列表: {exc}") return [] diff --git a/backend/package/yuxi/external_systems/framework/executor/system_executor.py b/backend/package/yuxi/external_systems/framework/executor/system_executor.py index 3f45d00c..96317825 100644 --- a/backend/package/yuxi/external_systems/framework/executor/system_executor.py +++ b/backend/package/yuxi/external_systems/framework/executor/system_executor.py @@ -39,6 +39,7 @@ from yuxi.external_systems.exceptions import ( AuthError, CircuitOpenError, ExecutionError, + FrameworkError, QuotaExceededError, RateLimitExceededError, SecretResolutionError, @@ -275,7 +276,7 @@ class SystemExecutor: context_builder 未注入时抛 NotImplementedError(过渡方案,见设计方案 §10.2)。 """ if self._context_builder is None: - raise NotImplementedError("context_builder 未注入,系统级路径暂不可用") + raise FrameworkError("context_builder 未注入,系统级路径暂不可用") return await self._context_builder.build(tool, env) def _build_hooks( diff --git a/backend/package/yuxi/external_systems/framework/runtime/secret_rotation_scheduler.py b/backend/package/yuxi/external_systems/framework/runtime/secret_rotation_scheduler.py index d932876f..e5ce3081 100644 --- a/backend/package/yuxi/external_systems/framework/runtime/secret_rotation_scheduler.py +++ b/backend/package/yuxi/external_systems/framework/runtime/secret_rotation_scheduler.py @@ -28,6 +28,10 @@ from yuxi.external_systems.core.contracts import ( SecretResolver, ) from yuxi.external_systems.core.ports import SecretRotationPolicyRepository +from yuxi.external_systems.exceptions import ( + DomainValidationError, + EntityNotFoundError, +) from yuxi.utils.datetime_utils import utc_now_naive @@ -74,9 +78,9 @@ class SecretRotationSchedulerImpl: """ policy = await self._repo.get_by_id(policy_id) if policy is None: - raise ValueError(f"轮换策略不存在: {policy_id}") + raise EntityNotFoundError("轮换策略不存在", details={"identifier": policy_id}) if policy.rotation_mode == "manual" and new_secret is None: - raise ValueError("manual 模式需要手动提供 new_secret") + raise DomainValidationError("manual 模式需要手动提供 new_secret") return {"success": True, "policy_id": policy_id, "new_secret": new_secret} async def get_overdue_policies(self) -> list[Any]: diff --git a/backend/package/yuxi/external_systems/framework/runtime/test_case_runner.py b/backend/package/yuxi/external_systems/framework/runtime/test_case_runner.py index a62bdd00..a08f010b 100644 --- a/backend/package/yuxi/external_systems/framework/runtime/test_case_runner.py +++ b/backend/package/yuxi/external_systems/framework/runtime/test_case_runner.py @@ -46,6 +46,10 @@ from collections.abc import Awaitable, Callable from typing import Any from yuxi.external_systems.core.ports import ToolTestCaseRepository +from yuxi.external_systems.exceptions import ( + EntityNotFoundError, + FrameworkError, +) from yuxi.utils.datetime_utils import utc_now_naive # 执行回调类型:tool_slug + parameters + env_key → 执行结果 @@ -241,11 +245,11 @@ class TestCaseRunnerImpl: """ callback = execute_callback or self._execute_callback if callback is None: - raise ValueError("未提供执行回调,无法运行测试用例") + raise FrameworkError("未提供执行回调,无法运行测试用例") test_case = await self._repo.get_by_id(test_case_id) if test_case is None: - raise ValueError(f"测试用例不存在: {test_case_id}") + raise EntityNotFoundError("测试用例不存在", details={"identifier": test_case_id}) started_at = utc_now_naive() try: diff --git a/backend/package/yuxi/external_systems/integrations/dynamics365/operations.py b/backend/package/yuxi/external_systems/integrations/dynamics365/operations.py index 48f0a339..8a10e59d 100644 --- a/backend/package/yuxi/external_systems/integrations/dynamics365/operations.py +++ b/backend/package/yuxi/external_systems/integrations/dynamics365/operations.py @@ -28,6 +28,7 @@ from yuxi.external_systems.exceptions import ( AuthError, DomainValidationError, ExecutionError, + FrameworkError, RateLimitExceededError, ) from yuxi.external_systems.integrations.dynamics365.error_extractor import ( @@ -238,7 +239,7 @@ def _ensure_dataverse_product_line(system_config: dict[str, Any]) -> None: if product_line == "dataverse": return if product_line == "finance_operations": - raise NotImplementedError("F&O 产品线将在 P1 实现") + raise FrameworkError("F&O 产品线将在 P1 实现") raise DomainValidationError(f"未知的 product_line: {product_line}") diff --git a/backend/package/yuxi/external_systems/integrations/sap_s4hana/operations.py b/backend/package/yuxi/external_systems/integrations/sap_s4hana/operations.py index f5b4b629..f74de511 100644 --- a/backend/package/yuxi/external_systems/integrations/sap_s4hana/operations.py +++ b/backend/package/yuxi/external_systems/integrations/sap_s4hana/operations.py @@ -35,6 +35,7 @@ from yuxi.external_systems.exceptions import ( AuthError, DomainValidationError, ExecutionError, + FrameworkError, RateLimitExceededError, ) from yuxi.external_systems.integrations.sap_s4hana.error_extractor import ( @@ -288,7 +289,7 @@ def _ensure_supported_deployment(system_config: dict[str, Any]) -> str: if deployment == "cloud": return deployment if deployment == "btp": - raise NotImplementedError("BTP 部署模式将在 P2 实现") + raise FrameworkError("BTP 部署模式将在 P2 实现") raise DomainValidationError(f"未知的 deployment: {deployment}") diff --git a/backend/package/yuxi/external_systems/integrations/slack/channel_resolver.py b/backend/package/yuxi/external_systems/integrations/slack/channel_resolver.py index 55f0dabb..47dfabf5 100644 --- a/backend/package/yuxi/external_systems/integrations/slack/channel_resolver.py +++ b/backend/package/yuxi/external_systems/integrations/slack/channel_resolver.py @@ -19,6 +19,10 @@ import re from collections.abc import Awaitable, Callable from typing import Any +from yuxi.external_systems.exceptions import ( + DomainValidationError, + EntityNotFoundError, +) from yuxi.external_systems.integrations.slack.constants import ( PAGE_SIZE, ) @@ -69,7 +73,7 @@ class SlackChannelResolver: return await self._resolve_channel_by_name(channel_input[1:]) if channel_input.startswith("@"): return await self._resolve_user_by_name(channel_input[1:]) - raise ValueError(f"无法解析的 channel 输入: {channel_input}(需以 # / @ 开头或为编码 ID)") + raise DomainValidationError(f"无法解析的 channel 输入: {channel_input}(需以 # / @ 开头或为编码 ID)") async def _resolve_channel_by_name(self, name: str) -> str: """通过 ``conversations.list`` cursor 分页查找 name 匹配的频道。""" @@ -92,7 +96,7 @@ class SlackChannelResolver: cursor = (resp.get("response_metadata") or {}).get("next_cursor", "") if not cursor: break - raise ValueError(f"未找到频道: #{name}") + raise EntityNotFoundError("频道不存在", details={"identifier": name}) async def _resolve_user_by_name(self, name: str) -> str: """通过 ``users.list`` cursor 分页查找 name 匹配的用户。""" @@ -112,4 +116,4 @@ class SlackChannelResolver: cursor = (resp.get("response_metadata") or {}).get("next_cursor", "") if not cursor: break - raise ValueError(f"未找到用户: @{name}") + raise EntityNotFoundError("用户不存在", details={"identifier": name}) diff --git a/backend/package/yuxi/external_systems/use_cases/services/import_service.py b/backend/package/yuxi/external_systems/use_cases/services/import_service.py index 27bf0863..5457dd25 100644 --- a/backend/package/yuxi/external_systems/use_cases/services/import_service.py +++ b/backend/package/yuxi/external_systems/use_cases/services/import_service.py @@ -783,8 +783,8 @@ class ImportService(ImportServicePort): return result if isinstance(raw, list): return [d if isinstance(d, dict) else d.model_dump() for d in raw] - raise TypeError( - f"适配器 generate_from_asset 返回了不支持的类型 {type(raw).__name__},预期 SystemImportDraft 或 list" + raise DomainValidationError( + f"适配器 generate_from_asset 返回了不支持的类型 {type(raw).__name__}" ) def _drafts_from_dicts(self, drafts: list[dict[str, Any]]) -> list[dict[str, Any]]: diff --git a/backend/package/yuxi/external_systems/use_cases/services/test_case_service.py b/backend/package/yuxi/external_systems/use_cases/services/test_case_service.py index d6da8d85..a02f4c08 100644 --- a/backend/package/yuxi/external_systems/use_cases/services/test_case_service.py +++ b/backend/package/yuxi/external_systems/use_cases/services/test_case_service.py @@ -25,6 +25,7 @@ from yuxi.external_systems.core.contracts import ( from yuxi.external_systems.exceptions import ( DomainValidationError, EntityNotFoundError, + FrameworkError, ) from yuxi.external_systems.use_cases.dto.test_case import ( AlertConfigOutput, @@ -256,7 +257,7 @@ class TestCaseService(TestCaseServicePort): """ runner = self._runtime.test_case_runner if runner is None: - raise RuntimeError("TestCaseRunner 未注入执行回调,无法执行测试用例") + raise FrameworkError("TestCaseRunner 未注入执行回调,无法执行测试用例") # 预检:验证用例存在(run 内部也会检查,但提前抛 404 更友好) test_case = await self._repos.tool_test_case.get_by_id(input_dto.case_id) @@ -317,7 +318,7 @@ class TestCaseService(TestCaseServicePort): runner = self._runtime.test_case_runner if runner is None: - raise RuntimeError("TestCaseRunner 未注入执行回调,无法执行测试用例") + raise FrameworkError("TestCaseRunner 未注入执行回调,无法执行测试用例") succeeded = 0 failed = 0 diff --git a/backend/package/yuxi/external_systems/use_cases/services/test_regression_service.py b/backend/package/yuxi/external_systems/use_cases/services/test_regression_service.py index bb66db83..a40a9254 100644 --- a/backend/package/yuxi/external_systems/use_cases/services/test_regression_service.py +++ b/backend/package/yuxi/external_systems/use_cases/services/test_regression_service.py @@ -27,6 +27,7 @@ from yuxi.external_systems.core.contracts import ( EventPublisher, UnitOfWork, ) +from yuxi.external_systems.exceptions import FrameworkError from yuxi.external_systems.use_cases.dto.test_case import ( ScheduledRegressionOutput, ) @@ -79,7 +80,7 @@ class TestRegressionService(TestRegressionServicePort): """ runner = self._runtime.test_case_runner if runner is None: - raise RuntimeError("TestCaseRunner 未注入执行回调,无法执行测试回归") + raise FrameworkError("TestCaseRunner 未注入执行回调,无法执行测试回归") due_cases = await self._repos.tool_test_case.list_scheduled()