ForcePilot/backend/package/yuxi/external_systems/integrations/dynamics365/operations.py

265 lines
10 KiB
Python
Raw Normal View History

"""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 不自行换取
tokentoken 获取是 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"]
headers = dict(_DATAVERSE_HEADERS)
headers["Authorization"] = f"Bearer {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
assert last_response is not None
if last_response.is_success:
return last_response
# 非 2xx解析错误 body 并映射为 ExternalSystemError 子类
try:
error_body = last_response.json()
except Exception:
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(),
}