ForcePilot/backend/package/yuxi/external_systems/integrations/hubspot/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

513 lines
20 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.

"""HubSpot discover / preview_tools / create_tools handler 实现 + 元数据发现 +
Search 渲染 + 错误提取器。
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``,发起元数据发现请求
4. 调用 ``HubSpotConfigGenerator`` 生成工具配置草稿
两层错误处理(见设计文档 §8
- **L1**: ``extract_hubspot_error`` — 注册到 ``http_error_extractor_registry``
由框架 executor 在运行时工具执行失败时调用,提取友好错误消息字符串。
- **L2**: ``map_hubspot_error`` — 不注册到任何注册表,仅在 handler 内部
discover/preview/create 阶段失败后调用,将 HTTP 状态码映射为
``ExternalSystemError`` 子类。
429 退避分层(见设计文档 §6.4
- discover/preview/create 阶段:本模块 ``_request_with_retry`` 实现
- 运行时工具执行:通过工具 ``retry_policy.retry_status_codes: [429]`` 配置
"""
from __future__ import annotations
import asyncio
from collections.abc import Awaitable, Callable
from typing import TYPE_CHECKING, Any
from yuxi.external_systems.exceptions import (
AccessDeniedError,
AuthError,
EntityNotFoundError,
ExecutionError,
QuotaExceededError,
RateLimitExceededError,
)
from yuxi.external_systems.integrations.hubspot.generators import (
HubSpotConfigGenerator,
)
from yuxi.external_systems.integrations.hubspot.metadata import (
API_BASE_URL,
ASSOCIATIONS_PATH_TEMPLATE,
DEFAULT_STANDARD_OBJECTS,
PROPERTIES_PATH_TEMPLATE,
SCHEMAS_PATH,
STANDARD_OBJECT_TYPE_IDS,
)
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
# HubSpot 通用请求头(除 Authorization 外)
_HUBSPOT_HEADERS: dict[str, str] = {
"Accept": "application/json",
"Content-Type": "application/json",
}
# ── 错误提取器L1──────────────────────────────────────────────────────
def extract_hubspot_error(response: httpx.Response) -> str | None:
"""从 HubSpot 错误响应中提取友好错误信息L1
HubSpot 错误响应为 JSON 对象,含顶层 ``status`` / ``message`` / ``correlationId``
与 ``errors`` 数组::
{
"status": "error",
"message": "Property values were not valid",
"correlationId": "a1b2c3d4-e5f6-7890",
"errors": [
{
"message": "The property 'email' is required",
"in": "propertyName",
"code": "REQUIRED_PROPERTY",
"context": {"propertyName": ["email"]}
}
],
"category": "VALIDATION_ERROR"
}
Returns:
拼接后的错误消息字符串(含 message + errors[].code/message + correlationId
非 HubSpot 错误格式时返回 None。
"""
try:
body = response.json()
except Exception:
return None
if not isinstance(body, dict):
return None
if body.get("status") != "error":
return None
parts: list[str] = []
top_message = body.get("message", "")
if top_message:
parts.append(top_message)
errors = body.get("errors") or []
for err in errors:
if not isinstance(err, dict):
continue
code = err.get("code", "UNKNOWN")
msg = err.get("message", "")
context = err.get("context") or {}
context_hint = f" (context: {context})" if context else ""
parts.append(f"[{code}] {msg}{context_hint}")
correlation_id = body.get("correlationId")
if correlation_id:
parts.append(f"correlationId: {correlation_id}")
return "; ".join(parts) if parts else None
def map_hubspot_error(status_code: int, error_body: dict[str, Any]) -> Exception:
"""将 HubSpot 错误响应转换为 ExternalSystemError 子类L2
仅在 handler 内部discover/preview/create 阶段)使用,不注册到框架。
运行时工具执行的错误由框架 executor 统一包装为 ExecutionError。
错误码映射见设计文档 §8.3。
"""
message = error_body.get("message", "") if isinstance(error_body, dict) else ""
category = error_body.get("category", "") if isinstance(error_body, dict) else ""
correlation_id = error_body.get("correlationId", "") if isinstance(error_body, dict) else ""
context_hint = f" (correlationId={correlation_id})" if correlation_id else ""
full_message = f"HubSpot: {message}{context_hint}" if message else f"HubSpot 错误{context_hint}"
if status_code == 401:
return AuthError(f"HubSpot 认证失败: {full_message} (category={category})")
if status_code == 403:
return AccessDeniedError(f"HubSpot 访问被拒: {full_message} (category={category})")
if status_code == 404:
return EntityNotFoundError(f"HubSpot 资源不存在: {full_message}")
if status_code == 409:
return ExecutionError(f"HubSpot 冲突: {full_message}")
if status_code == 429:
# RATE_LIMIT_EXCEEDED vs DAILY_LIMIT_EXCEEDED 按 category 区分
if category == "DAILY_LIMIT_EXCEEDED":
return QuotaExceededError(f"HubSpot 每日配额耗尽: {full_message}")
return RateLimitExceededError(
f"HubSpot 限流: {full_message}",
retry_after=60,
limit_type="qps",
)
if 500 <= status_code < 600:
return ExecutionError(f"HubSpot 服务端错误: {full_message} (status={status_code})")
return ExecutionError(f"HubSpot 未知错误: {full_message} (status={status_code})")
# ── Search API 请求体渲染 ─────────────────────────────────────────────────
def _render_search_body(
filter_groups: list[dict[str, Any]],
properties: list[str] | None = None,
sorts: list[dict[str, Any]] | None = None,
after: str | None = None,
limit: int = 50,
) -> dict[str, Any]:
"""渲染 HubSpot Search API 请求体。
Args:
filter_groups: 筛选组列表,每组内 filters 为 AND 关系,组间为 OR 关系。
每条 filter 含 propertyName / operator / value。
operator 支持: EQ / NEQ / LT / LTE / GT / GTE / BETWEEN / IN / NOT_IN /
CONTAINS_TOKEN / NOT_CONTAINS_TOKEN / HAS_PROPERTY / NOT_HAS_PROPERTY。
properties: 返回的属性列表(默认返回全部)。
sorts: 排序规则列表。
after: 分页游标(上一页响应的 after 值)。
limit: 每页记录数(最大 200
"""
body: dict[str, Any] = {"filterGroups": filter_groups}
if properties:
body["properties"] = properties
if sorts:
body["sorts"] = sorts
paging: dict[str, Any] = {"limit": min(limit, 200)}
if after:
paging["after"] = after
body["paging"] = paging
return body
# ── system_config 解析辅助 ────────────────────────────────────────────────
def _build_auth_headers(system_config: dict[str, Any]) -> dict[str, str]:
"""从 system_config 提取 token构建 HubSpot 请求头。
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("hubspot: system_config 缺少 _resolved_tokenuse_cases 层未注入 token")
access_token = token_info["access_token"]
headers = dict(_HUBSPOT_HEADERS)
headers["Authorization"] = f"Bearer {access_token}"
return headers
def _resolve_base_url(system_config: dict[str, Any]) -> str:
"""从 system_config 提取 base_url默认 ``https://api.hubapi.com``。"""
connection_config = system_config.get("connection_config") or {}
base_url = connection_config.get("base_url") or API_BASE_URL
return str(base_url).rstrip("/")
def _resolve_auth_type(system_config: dict[str, Any]) -> str:
"""从 system_config 提取 auth_type默认 private_app_token。"""
return str(system_config.get("auth_type", "private_app_token"))
def _resolve_selected_objects(system_config: dict[str, Any]) -> list[str]:
"""从 system_config 提取用户选择的标准对象 FQN 列表。
默认返回 ``DEFAULT_STANDARD_OBJECTS``contacts / companies / deals / tickets
"""
discovery_options = system_config.get("discovery_options") or {}
selected = discovery_options.get("selected_objects")
if selected:
return list(selected)
return list(DEFAULT_STANDARD_OBJECTS)
def _resolve_association_pairs(
system_config: dict[str, Any],
) -> list[tuple[str, str]]:
"""从 system_config 提取需要发现关联标签的对象对列表。
返回 ``[(from_type, to_type), ...]``,默认空列表(不发现关联)。
``discovery_options.association_pairs`` 格式:``[{"from": "0-1", "to": "0-2"}]``。
"""
discovery_options = system_config.get("discovery_options") or {}
pairs_raw = discovery_options.get("association_pairs") or []
pairs: list[tuple[str, str]] = []
for pair in pairs_raw:
if not isinstance(pair, dict):
continue
from_type = pair.get("from")
to_type = pair.get("to")
if from_type and to_type:
pairs.append((str(from_type), str(to_type)))
return pairs
# ── 429 退避 HTTP 请求 ────────────────────────────────────────────────────
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_hubspot_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)
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"HubSpot 限流,已重试 {max_retries} 次仍失败: {url}",
retry_after=retry_after,
limit_type="qps",
)
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"HubSpot 网络请求失败: {exc}") from exc
assert last_response is not None
if last_response.is_success:
return last_response
try:
error_body = last_response.json()
except Exception:
error_body = {"message": last_response.text}
raise map_hubspot_error(last_response.status_code, error_body)
def _make_request_fn() -> Callable[..., Awaitable[httpx.Response]]:
"""构造带 429 退避的 request_fn供元数据发现函数使用。"""
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_schemas(
request_fn: Callable[..., Awaitable[httpx.Response]],
base_url: str,
headers: dict[str, str],
) -> list[dict[str, Any]]:
"""调用 ``GET /crm/v3/schemas`` 获取自定义对象 schema 列表。
自定义对象类型 ID 为 ``2-XXX``;标准对象 schema 不在此处列出。
"""
response = await request_fn("GET", f"{base_url}{SCHEMAS_PATH}", headers)
data = response.json()
return data.get("results", []) if isinstance(data, dict) else []
async def _discover_properties(
request_fn: Callable[..., Awaitable[httpx.Response]],
base_url: str,
object_type_id: str,
headers: dict[str, str],
) -> list[dict[str, Any]]:
"""调用 ``GET /crm/v3/properties/{objectTypeId}`` 获取对象属性元数据。"""
path = PROPERTIES_PATH_TEMPLATE.format(object_type_id=object_type_id)
response = await request_fn("GET", f"{base_url}{path}", headers)
data = response.json()
return data.get("results", []) if isinstance(data, dict) else []
async def _discover_association_labels(
request_fn: Callable[..., Awaitable[httpx.Response]],
base_url: str,
from_type: str,
to_type: str,
headers: dict[str, str],
) -> list[dict[str, Any]]:
"""调用 ``GET /crm/v4/associations/{from}/{to}/labels`` 获取关联类型标签。"""
path = ASSOCIATIONS_PATH_TEMPLATE.format(**{"from": from_type, "to": to_type})
response = await request_fn("GET", f"{base_url}{path}", headers)
data = response.json()
return data.get("results", []) if isinstance(data, dict) else []
# ── 三个 OperationHandler 实现 ────────────────────────────────────────────
async def discover_hubspot(
source_type: str,
system_config: dict[str, Any],
) -> list[dict[str, Any]]:
"""discover handler发现 HubSpot 可用对象列表(标准对象 + 自定义对象 Schemas
Returns:
对象摘要列表,每项含 ``object_type_id`` / ``name`` / ``is_standard`` /
``label``。
"""
base_url = _resolve_base_url(system_config)
auth_headers = _build_auth_headers(system_config)
selected_objects = _resolve_selected_objects(system_config)
request_fn = _make_request_fn()
# 标准对象(用户选择的)
results: list[dict[str, Any]] = []
for fqn in selected_objects:
type_id = STANDARD_OBJECT_TYPE_IDS.get(fqn, fqn)
results.append(
{
"object_type_id": type_id,
"name": fqn,
"label": fqn.replace("_", " ").title(),
"is_standard": True,
}
)
# 自定义对象Schemas API
custom_schemas = await _discover_schemas(request_fn, base_url, auth_headers)
for schema in custom_schemas:
type_id = schema.get("objectTypeId", "")
name = schema.get("name", type_id)
labels = schema.get("labels") or {}
label = labels.get("singular", name) if isinstance(labels, dict) else name
results.append(
{
"object_type_id": type_id,
"name": name,
"label": label,
"is_standard": False,
}
)
return results
async def preview_hubspot_tools(
source_type: str,
system_config: dict[str, Any],
) -> list[dict[str, Any]]:
"""preview_tools handler预览将生成的工具列表。
Returns:
工具预览列表,每项为 ExternalToolCreateInput 兼容的 dict。
"""
payload = await _build_generator_payload(system_config)
generator = HubSpotConfigGenerator()
return await generator.generate(payload)
async def create_hubspot_tools(
source_type: str,
system_config: dict[str, Any],
) -> GeneratedToolsDraft:
"""create_tools handler生成工具草稿不直接持久化
Returns:
GeneratedToolsDraft由 use_cases 层统一持久化。
"""
payload = await _build_generator_payload(system_config)
generator = HubSpotConfigGenerator()
tool_configs = await generator.generate(payload)
return GeneratedToolsDraft(tool_configs=tool_configs, override_existing=False)
# ── 生成器 payload 构造 ───────────────────────────────────────────────────
async def _build_generator_payload(system_config: dict[str, Any]) -> dict[str, Any]:
"""从 system_config 构造生成器 payload。
流程:
1. 解析 base_url / auth_headers / auth_type / auth_config
2. 解析用户选择的标准对象 FQN 列表
3. 发现自定义对象 Schemas
4. 为每个对象(标准 + 自定义)发现属性元数据
5. 可选:发现关联标签(基于 discovery_options.association_pairs
6. 汇总为生成器 payload
"""
base_url = _resolve_base_url(system_config)
auth_headers = _build_auth_headers(system_config)
auth_type = _resolve_auth_type(system_config)
auth_config = system_config.get("auth_config") or {}
selected_objects = _resolve_selected_objects(system_config)
association_pairs = _resolve_association_pairs(system_config)
request_fn = _make_request_fn()
# 发现自定义对象 Schemas
custom_schemas = await _discover_schemas(request_fn, base_url, auth_headers)
# 合并标准对象与自定义对象 → (objectTypeId, fqn) 列表
all_objects: list[tuple[str, str]] = []
for fqn in selected_objects:
type_id = STANDARD_OBJECT_TYPE_IDS.get(fqn, fqn)
all_objects.append((type_id, fqn))
for schema in custom_schemas:
type_id = schema.get("objectTypeId", "")
fqn = schema.get("name", type_id)
if type_id:
all_objects.append((type_id, fqn))
# 为每个对象发现属性元数据
properties_map: dict[str, list[dict[str, Any]]] = {}
for type_id, _fqn in all_objects:
properties_map[type_id] = await _discover_properties(request_fn, base_url, type_id, auth_headers)
# 可选:发现关联标签
association_labels: list[dict[str, Any]] = []
for from_type, to_type in association_pairs:
labels = await _discover_association_labels(request_fn, base_url, from_type, to_type, auth_headers)
if labels:
association_labels.append({"from": from_type, "to": to_type, "labels": labels})
return {
"standard_objects": selected_objects,
"custom_schemas": custom_schemas,
"properties_map": properties_map,
"association_labels": association_labels,
"base_url": base_url,
"auth_type": auth_type,
"auth_config": auth_config,
}