ForcePilot/backend/package/yuxi/external_systems/integrations/zendesk/operations.py
Kris 74709ba2d3 feat: 完成外部系统限界上下文核心代码实现
新增六边形架构核心代码包,包含:
1. 协议适配器层:HTTP/SMTP/IMAP/SSH/GRPC等多协议适配器实现
2. 认证插件体系:基础认证、API密钥、HMAC等多类型认证插件
3. 执行编排框架:工具执行器、上下文构建、运行时治理组件
4. 用例端口与DTO:定义领域服务端口与数据传输对象
5. 厂商集成包框架:支持第三方系统集成扩展
6. 基础设施装配层:实现依赖注入与服务装配

所有代码遵循六边形架构设计原则,实现端口与适配器解耦,支持动态扩展与自动发现。
2026-06-20 22:12:42 +08:00

276 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.

"""Zendesk discover / preview_tools / create_tools handler 实现。
handler 签名遵循 ``OperationHandler`` 协议:
``(source_type: str, system_config: dict[str, Any]) -> Awaitable[Any]``。
handler 内部职责(见设计文档 §7.4
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_zendesk_metadata`` 或 ``ZendeskConfigGenerator``
429 退避分层(见设计文档 §5.6
- discover/preview/create 阶段:本模块 ``_request_with_retry`` 实现(解析 Retry-After
- 运行时工具执行:通过工具 ``retry_policy.retry_status_codes: [429, 502, 503, 504]``
配置(见 generators.py
认证头构建(见设计文档 §3.5
- OAuth2: ``system_config["_resolved_token"]["access_token"]`` → Bearer
- basic (API Token): ``system_config["auth_config"]["username"]`` / ``["password"]`` → Basic
- token 由 use_cases 层通过 token_manager.get_token 预处理后注入到
``system_config["_resolved_token"]``handler 不自行换取 token
"""
from __future__ import annotations
import asyncio
import base64
from collections.abc import Awaitable, Callable
from typing import TYPE_CHECKING, Any
from yuxi.external_systems.exceptions import (
AuthError,
DomainValidationError,
ExecutionError,
RateLimitExceededError,
)
from yuxi.external_systems.integrations.schemas import GeneratedToolsDraft
from yuxi.external_systems.integrations.zendesk.error_extractor import (
map_zendesk_error,
)
from yuxi.external_systems.integrations.zendesk.generators import (
ZendeskConfigGenerator,
)
from yuxi.external_systems.integrations.zendesk.metadata import (
resolve_oauth_endpoints,
)
from yuxi.external_systems.integrations.zendesk.zendesk_metadata import (
discover_zendesk_metadata,
)
if TYPE_CHECKING:
import httpx
# discover/preview/create 阶段 429 退避参数
_RETRY_MAX_ATTEMPTS = 5
_RETRY_BASE_DELAY = 1.0
# Zendesk 通用请求头(除 Authorization 外)
_ZENDESK_HEADERS: dict[str, str] = {
"Accept": "application/json",
"Content-Type": "application/json",
}
def _build_auth_headers(system_config: dict[str, Any]) -> dict[str, str]:
"""从 system_config 提取凭证,构建 Zendesk 请求头。
两种认证形态:
- OAuth2: ``system_config["_resolved_token"]["access_token"]`` → Bearer
- basic (API Token): ``system_config["auth_config"]["username"]`` / ``["password"]`` → Basic
token 由 use_cases 层通过 ``token_manager.get_token`` 预处理后注入到
``system_config["_resolved_token"]``。handler 不自行换取 token。
"""
headers = dict(_ZENDESK_HEADERS)
token_info = system_config.get("_resolved_token")
if isinstance(token_info, dict) and token_info.get("access_token"):
headers["Authorization"] = f"Bearer {token_info['access_token']}"
return headers
# 非 token 类认证basic由 use_cases 层解析后注入 auth_config
auth_config = system_config.get("auth_config") or {}
username = auth_config.get("username")
password = auth_config.get("password")
if username and password:
encoded = base64.b64encode(f"{username}:{password}".encode()).decode()
headers["Authorization"] = f"Basic {encoded}"
return headers
raise AuthError("zendesk: system_config 缺少 _resolved_token 或 auth_config 凭证use_cases 层未注入凭证")
def _resolve_base_url(system_config: dict[str, Any]) -> str:
"""从 system_config 提取 base_url支持 subdomain 拼接。"""
connection_config = system_config.get("connection_config") or {}
base_url = connection_config.get("base_url")
if not base_url:
subdomain = connection_config.get("subdomain")
if not subdomain:
raise DomainValidationError("zendesk: system_config.connection_config 需包含 base_url 或 subdomain")
base_url = f"https://{subdomain}.zendesk.com"
return str(base_url).rstrip("/")
def _resolve_resources(system_config: dict[str, Any]) -> list[str] | None:
"""从 system_config 提取资源过滤白名单。"""
discovery_options = system_config.get("discovery_options") or {}
return discovery_options.get("resources")
async def _request_with_retry(
method: str,
url: str,
headers: dict[str, str],
params: dict[str, Any] | None = None,
*,
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_zendesk_error`` 转换为 ExternalSystemError 子类。
网络层异常(超时 / 连接错误)转换为 ``ExecutionError``。
"""
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, params=params)
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"Zendesk 限流,已重试 {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"Zendesk 网络请求失败: {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_zendesk_error(last_response.status_code, error_body)
def _make_request_fn() -> Callable[
[str, str, dict[str, str], dict[str, Any] | None],
Awaitable[httpx.Response],
]:
"""构造带 429 退避的 request_fn供 discover_zendesk_metadata 使用。"""
async def request_fn(
method: str,
url: str,
headers: dict[str, str],
params: dict[str, Any] | None = None,
) -> httpx.Response:
return await _request_with_retry(method, url, headers, params)
return request_fn
async def discover_zendesk(
source_type: str,
system_config: dict[str, Any],
) -> list[dict[str, Any]]:
"""discover handler发现 Zendesk 实例可用资源摘要。
Returns:
资源摘要列表,每项含 ``name`` / ``display_name`` / ``field_count``。
"""
base_url = _resolve_base_url(system_config)
auth_headers = _build_auth_headers(system_config)
resources = _resolve_resources(system_config)
request_fn = _make_request_fn()
metadata = await discover_zendesk_metadata(
base_url,
auth_headers,
resources=resources,
request_fn=request_fn,
)
return [
{
"name": r.name,
"display_name": r.display_name,
"field_count": len(r.fields),
}
for r in metadata
]
async def preview_zendesk_tools(
source_type: str,
system_config: dict[str, Any],
) -> list[dict[str, Any]]:
"""preview_tools handler预览将生成的工具列表。
Returns:
工具预览列表,每项为 ExternalToolCreateInput 兼容的 dict。
"""
payload = _build_generator_payload(system_config)
generator = ZendeskConfigGenerator()
return await generator.generate(payload)
async def create_zendesk_tools(
source_type: str,
system_config: dict[str, Any],
) -> GeneratedToolsDraft:
"""create_tools handler生成工具草稿不直接持久化
Returns:
GeneratedToolsDraft由 use_cases 层统一持久化。
"""
payload = _build_generator_payload(system_config)
generator = ZendeskConfigGenerator()
tool_configs = await generator.generate(payload)
return GeneratedToolsDraft(tool_configs=tool_configs, override_existing=False)
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
- resources: list[str] | None
- request_fn: 带 429 退避的 HTTP 请求函数
OAuth2 模式需将 ``token_url`` 注入 ``auth_config``(供框架级插件刷新)。
"""
# OAuth2 模式需将 token_url 注入 auth_config供框架级插件刷新
auth_config = dict(system_config.get("auth_config") or {})
if auth_config.get("auth_type") == "oauth2_authorization_code":
base_url = _resolve_base_url(system_config)
endpoints = resolve_oauth_endpoints(base_url)
auth_config.setdefault("token_url", endpoints["token_url"])
return {
"base_url": _resolve_base_url(system_config),
"auth_headers": _build_auth_headers(system_config),
"auth_config": auth_config,
"auth_type": system_config.get("auth_type", "basic"),
"resources": _resolve_resources(system_config),
"request_fn": _make_request_fn(),
}