ForcePilot/backend/package/yuxi/external_systems/integrations/dynamics365/operations.py
Kris 55188ff7fa refactor: 整理代码结构并修复多处细节问题
1. 调整导入顺序与格式,修复重复导入
2. 替换通用异常捕获为更精准的ValueError
3. 重构持久层仓库的get_deleted/list_deleted方法,添加ORM转领域模型逻辑
4. 完善配置校验与参数合法性检查,新增缺失字段校验
5. 优化多行字符串与函数调用的换行格式
6. 修复通知服务状态判定逻辑与文档描述
7. 简化无用的TYPE_CHECKING代码块
8. 重构ServiceNow元数据发现逻辑,增加字段合法性校验
9. 修复数据库查询验证器的子查询处理逻辑
10. 完善领域模型的初始化校验与属性语义
2026-07-11 07:34:48 +08:00

267 lines
10 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Dynamics365 discover / preview_tools / create_tools handler 实现。
handler 签名遵循 ``OperationHandler`` 协议:
``(source_type: str, system_config: dict[str, Any]) -> Awaitable[Any]``。
handler 内部职责:
1. 从 ``system_config`` 提取 ``base_url`` / ``auth_config`` / ``_resolved_token`` 等
2. 构建 ``auth_headers``token 由 use_cases 层注入到 ``system_config["_resolved_token"]``
3. 构造带 429 退避的 ``request_fn``,注入到生成器 payload
4. 调用 ``discover_dataverse_metadata`` 或 ``Dynamics365ConfigGenerator``
product_line 分派(首期仅 dataverse
- ``dataverse``:走 ``_discover_dataverse`` 路径
- ``finance_operations``P1 延后,抛 ``NotImplementedError``
429 退避分层(见设计文档 §6.7
- discover/preview/create 阶段:本模块 ``_request_with_retry`` 实现(解析 Retry-After
- 运行时工具执行:通过工具 ``retry_policy.retry_status_codes: [429]`` 配置(见 generators.py
"""
from __future__ import annotations
import asyncio
from collections.abc import Awaitable, Callable
from typing import TYPE_CHECKING, Any
from yuxi.external_systems.exceptions import (
AuthError,
DomainValidationError,
ExecutionError,
FrameworkError,
RateLimitExceededError,
)
from yuxi.external_systems.integrations.dynamics365.error_extractor import (
map_dataverse_error,
)
from yuxi.external_systems.integrations.dynamics365.generators import (
Dynamics365ConfigGenerator,
)
from yuxi.external_systems.integrations.dynamics365.odata_metadata import (
discover_dataverse_metadata,
)
from yuxi.external_systems.integrations.schemas import GeneratedToolsDraft
if TYPE_CHECKING:
import httpx
# discover/preview/create 阶段 429 退避参数
_RETRY_MAX_ATTEMPTS = 5
_RETRY_BASE_DELAY = 1.0
# Dataverse 通用请求头(除 Authorization 外)
_DATAVERSE_HEADERS: dict[str, str] = {
"OData-Version": "4.0",
"OData-MaxVersion": "4.0",
"Accept": "application/json",
}
def _build_auth_headers(system_config: dict[str, Any]) -> dict[str, str]:
"""从 system_config 提取 token构建 Dataverse 请求头。
system_config 中 token 由 use_cases 层通过 ``token_manager.get_token`` 预处理后
注入到 ``system_config["_resolved_token"]["access_token"]``。handler 不自行换取
token——token 获取是 use_cases 层的职责。
"""
token_info = system_config.get("_resolved_token")
if not isinstance(token_info, dict) or not token_info.get("access_token"):
raise AuthError("dynamics365: system_config 缺少 _resolved_tokenuse_cases 层未注入 token")
access_token = token_info["access_token"]
token_type = token_info.get("token_type", "Bearer")
headers = dict(_DATAVERSE_HEADERS)
headers["Authorization"] = f"{token_type} {access_token}"
headers["Content-Type"] = "application/json; charset=utf-8"
return headers
def _resolve_base_url(system_config: dict[str, Any]) -> str:
"""从 system_config 提取 base_url。"""
connection_config = system_config.get("connection_config") or {}
base_url = connection_config.get("base_url")
if not base_url:
raise DomainValidationError("dynamics365: system_config.connection_config.base_url 不能为空")
return str(base_url)
def _resolve_product_line(system_config: dict[str, Any]) -> str:
"""从 system_config 提取 product_line默认 dataverse。"""
connection_config = system_config.get("connection_config") or {}
return str(connection_config.get("product_line", "dataverse"))
def _resolve_entity_filter(system_config: dict[str, Any]) -> list[str] | None:
"""从 system_config 提取 entity_filter 白名单。"""
discovery_options = system_config.get("discovery_options") or {}
return discovery_options.get("entity_filter")
async def _request_with_retry(
method: str,
url: str,
headers: dict[str, str],
*,
max_retries: int = _RETRY_MAX_ATTEMPTS,
base_delay: float = _RETRY_BASE_DELAY,
) -> httpx.Response:
"""handler 内部 HTTP 调用的 429 退避(仅用于 discover/preview/create 阶段)。
优先使用响应头 ``Retry-After``,否则指数退避(``base_delay * 2 ** attempt``)。
运行时工具执行的 429 退避由框架 executor + 工具 retry_policy 处理,
不在此函数范围内。
非 2xx 响应(非 429通过 ``map_dataverse_error`` 转换为 ExternalSystemError 子类。
网络层异常(超时 / 连接错误)转换为 ``ExecutionError``(遵循设计文档 §8.5)。
"""
import httpx
try:
async with httpx.AsyncClient() as client:
last_response: httpx.Response | None = None
for attempt in range(max_retries + 1):
response = await client.request(method, url, headers=headers)
if response.status_code != 429:
last_response = response
break
if attempt == max_retries:
retry_after_header = response.headers.get("Retry-After", "60")
try:
retry_after = int(retry_after_header)
except ValueError:
retry_after = 60
raise RateLimitExceededError(
f"Dataverse 限流,已重试 {max_retries} 次仍失败: {url}",
retry_after=retry_after,
limit_type="qps",
)
# 优先使用 Retry-After 头,否则指数退避
retry_after_header = response.headers.get("Retry-After")
if retry_after_header:
try:
delay = float(retry_after_header)
except ValueError:
delay = base_delay * (2**attempt)
else:
delay = base_delay * (2**attempt)
await asyncio.sleep(delay)
except httpx.RequestError as exc:
raise ExecutionError(f"Dataverse 网络请求失败: {exc}") from exc
if last_response is None:
raise ExecutionError("Dataverse 请求未返回有效响应")
if last_response.is_success:
return last_response
# 非 2xx解析错误 body 并映射为 ExternalSystemError 子类
try:
error_body = last_response.json()
except ValueError:
error_body = {"error": {"message": last_response.text}}
raise map_dataverse_error(last_response.status_code, error_body)
def _make_request_fn() -> Callable[[str, str, dict[str, str]], Awaitable[httpx.Response]]:
"""构造带 429 退避的 request_fn供 discover_dataverse_metadata 使用。"""
async def request_fn(method: str, url: str, headers: dict[str, str]) -> httpx.Response:
return await _request_with_retry(method, url, headers)
return request_fn
async def discover_dynamics365(
source_type: str,
system_config: dict[str, Any],
) -> list[dict[str, Any]]:
"""discover handler发现 Dataverse 实体元数据摘要。
Returns:
实体摘要列表,每项含 ``logical_name`` / ``display_name`` / ``entity_set_name`` /
``primary_id_attribute``。
"""
_ensure_dataverse_product_line(system_config)
base_url = _resolve_base_url(system_config)
auth_headers = _build_auth_headers(system_config)
entity_filter = _resolve_entity_filter(system_config)
request_fn = _make_request_fn()
metadata = await discover_dataverse_metadata(
base_url,
auth_headers,
entity_filter=entity_filter,
request_fn=request_fn,
)
return [
{
"logical_name": e.logical_name,
"schema_name": e.schema_name,
"display_name": e.display_name,
"entity_set_name": e.entity_set_name,
"primary_id_attribute": e.primary_id_attribute,
"attribute_count": len(e.attributes),
"navigation_property_count": len(e.navigation_properties),
}
for e in metadata
]
async def preview_dynamics365_tools(
source_type: str,
system_config: dict[str, Any],
) -> list[dict[str, Any]]:
"""preview_tools handler预览将生成的工具列表。
Returns:
工具预览列表,每项为 ExternalToolCreateInput 兼容的 dict。
"""
_ensure_dataverse_product_line(system_config)
payload = _build_generator_payload(system_config)
generator = Dynamics365ConfigGenerator()
return await generator.generate(payload)
async def create_dynamics365_tools(
source_type: str,
system_config: dict[str, Any],
) -> GeneratedToolsDraft:
"""create_tools handler生成工具草稿不直接持久化
Returns:
GeneratedToolsDraft由 use_cases 层统一持久化。
"""
_ensure_dataverse_product_line(system_config)
payload = _build_generator_payload(system_config)
generator = Dynamics365ConfigGenerator()
tool_configs = await generator.generate(payload)
return GeneratedToolsDraft(tool_configs=tool_configs, override_existing=False)
def _ensure_dataverse_product_line(system_config: dict[str, Any]) -> None:
"""校验 product_line首期仅支持 dataverse。"""
product_line = _resolve_product_line(system_config)
if product_line == "dataverse":
return
if product_line == "finance_operations":
raise FrameworkError("F&O 产品线将在 P1 实现")
raise DomainValidationError(f"未知的 product_line: {product_line}")
def _build_generator_payload(system_config: dict[str, Any]) -> dict[str, Any]:
"""从 system_config 构造生成器 payload。
生成器 payload 是 system_config 的子集,包含:
- base_url: str
- auth_headers: dict[str, str]
- auth_config: dict
- auth_type: str
- entity_filter: list[str] | None
- request_fn: 带 429 退避的 HTTP 请求函数
"""
return {
"base_url": _resolve_base_url(system_config),
"auth_headers": _build_auth_headers(system_config),
"auth_config": system_config.get("auth_config") or {},
"auth_type": system_config.get("auth_type", "oauth2_client_credentials"),
"entity_filter": _resolve_entity_filter(system_config),
"request_fn": _make_request_fn(),
}