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

339 lines
13 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.

"""Microsoft 365 discover / preview_tools / create_tools handler 实现。
handler 签名遵循 ``OperationHandler`` 协议:
``(source_type: str, system_config: dict[str, Any]) -> Awaitable[Any]``。
handler 内部职责(见设计文档 §3.7 / §4.2 / §6.5
1. 从 ``system_config`` 提取 ``cloud`` / ``api_version`` / ``auth_type`` /
``_resolved_token`` 等。
2. 推导 ``auth_mode````application`` / ``delegated``)。
3. 构建 ``auth_headers``token 由 use_cases 层注入到
``system_config["_resolved_token"]``)。
4. 构造带 429 退避的 ``request_fn``,可选调用 ``GET /v1.0/$metadata`` 补全字段类型。
5. 调用 ``Microsoft365ConfigGenerator`` 生成 Tool 配置草稿。
429 退避分层(见设计文档 §6.5
- **L1: handler 内部退避** — 本模块 ``_request_with_retry`` 实现(解析 Retry-After
- **L2: 工具运行时退避** — 通过工具 ``retry_policy.retry_status_codes: [429]`` 配置,
由框架 executor 基于 tenacity 重试。
"""
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,
RateLimitExceededError,
)
from yuxi.external_systems.integrations.microsoft365.cloud_endpoints import (
resolve_graph_host,
validate_cloud,
)
from yuxi.external_systems.integrations.microsoft365.error_extractor import (
map_microsoft365_error,
)
from yuxi.external_systems.integrations.microsoft365.generators import (
Microsoft365ConfigGenerator,
)
from yuxi.external_systems.integrations.microsoft365.resource_catalog import (
RESOURCE_CATALOG,
TEAMS_RESOURCE_CATALOG,
TEAMS_RESOURCES,
)
from yuxi.external_systems.integrations.microsoft365.teams_permissions import (
get_teams_permissions,
)
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
# Graph 通用请求头(除 Authorization 外)
_GRAPH_HEADERS: dict[str, str] = {
"Accept": "application/json",
"Content-Type": "application/json; charset=utf-8",
}
# auth_type -> auth_mode 映射
_AUTH_TYPE_TO_MODE: dict[str, str] = {
"microsoft365_client_credentials": "application",
"microsoft365_authorization_code": "delegated",
}
def _resolve_auth_mode(system_config: dict[str, Any]) -> str:
"""从 ``system_config["auth_type"]`` 推导 ``auth_mode``。
- ``microsoft365_client_credentials`` → ``application``
- ``microsoft365_authorization_code`` → ``delegated``
"""
auth_type = system_config.get("auth_type", "")
if auth_type not in _AUTH_TYPE_TO_MODE:
raise DomainValidationError(
f"microsoft365 不支持的 auth_type: {auth_type},仅允许 {list(_AUTH_TYPE_TO_MODE.keys())}"
)
return _AUTH_TYPE_TO_MODE[auth_type]
def _resolve_cloud(system_config: dict[str, Any]) -> str:
"""从 ``system_config["connection_config"]["cloud"]`` 读取并校验 cloud。"""
connection_config = system_config.get("connection_config") or {}
cloud = connection_config.get("cloud", "global")
return validate_cloud(cloud)
def _resolve_api_version(system_config: dict[str, Any]) -> str:
"""从 ``system_config["connection_config"]["api_version"]`` 读取,默认 ``v1.0``。"""
connection_config = system_config.get("connection_config") or {}
return str(connection_config.get("api_version", "v1.0"))
def _resolve_base_url(system_config: dict[str, Any]) -> str:
"""从 ``system_config["connection_config"]["base_url"]`` 读取,缺省由 cloud 推导。"""
connection_config = system_config.get("connection_config") or {}
base_url = connection_config.get("base_url")
if base_url:
return str(base_url).rstrip("/")
cloud = _resolve_cloud(system_config)
return resolve_graph_host(cloud)
def _resolve_resources(system_config: dict[str, Any]) -> list[str]:
"""从 ``discovery_options.resources`` 读取,缺省返回全部 P0 + Teams 资源维度。"""
discovery_options = system_config.get("discovery_options") or {}
resources = discovery_options.get("resources")
if resources:
return list(resources)
return [item["resource"] for item in RESOURCE_CATALOG] + [item["resource"] for item in TEAMS_RESOURCE_CATALOG]
def _build_auth_headers(system_config: dict[str, Any]) -> dict[str, str]:
"""从 ``system_config["_resolved_token"]`` 提取 token构建 Graph 请求头。
handler 不自行换取 token——token 获取/刷新由 use_cases 层通过
``TokenManager.get_token`` 预处理后注入到 ``system_config["_resolved_token"]``。
若 ``_resolved_token`` 缺失,抛 ``AuthError``(与 dynamics365 一致)。
"""
token_info = system_config.get("_resolved_token")
if not isinstance(token_info, dict) or not token_info.get("access_token"):
raise AuthError("microsoft365: system_config 缺少 _resolved_tokenuse_cases 层未注入 token")
access_token = token_info["access_token"]
headers = dict(_GRAPH_HEADERS)
headers["Authorization"] = f"Bearer {access_token}"
return headers
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_microsoft365_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"Microsoft Graph 限流,已重试 {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"Microsoft Graph 网络请求失败: {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_microsoft365_error(last_response.status_code, error_body)
def _make_request_fn() -> Callable[[str, str, dict[str, str]], 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
def _build_generator_payload(
system_config: dict[str, Any],
auth_headers: dict[str, str],
) -> dict[str, Any]:
"""从 system_config 构造生成器 payload见 ``Microsoft365ConfigGenerator.generate``)。"""
return {
"auth_mode": _resolve_auth_mode(system_config),
"cloud": _resolve_cloud(system_config),
"api_version": _resolve_api_version(system_config),
"base_url": _resolve_base_url(system_config),
"auth_headers": auth_headers,
"auth_type": system_config.get("auth_type", "microsoft365_client_credentials"),
"auth_config": system_config.get("auth_config") or {},
"resources": _resolve_resources(system_config),
"request_fn": _make_request_fn(),
}
def _has_teams_resources(resources: list[str]) -> bool:
"""判断资源列表中是否包含 Teams 维度资源。"""
return any(resource in TEAMS_RESOURCES for resource in resources)
async def _verify_teams_permissions(
system_config: dict[str, Any],
auth_mode: str,
auth_headers: dict[str, str],
) -> None:
"""验证 Teams 权限已授予(见设计文档 §4.2 / §6.4)。
通过调用 Graph API 健康检查端点验证 Teams 权限:
- application 模式:``GET {base_url}/v1.0/teams?$top=1``
- delegated 模式:``GET {base_url}/v1.0/me/joinedTeams``
权限验证失败时由 ``_request_with_retry`` → ``map_microsoft365_error`` 抛出
``AccessDeniedError`` / ``AuthError``**不静默标记 ``permission_missing``**——
权限缺失属于配置问题,应在 discover 阶段暴露而非运行时触发 403。
"""
base_url = _resolve_base_url(system_config)
api_version = _resolve_api_version(system_config)
if auth_mode == "application":
url = f"{base_url}/{api_version}/teams?$top=1"
else:
url = f"{base_url}/{api_version}/me/joinedTeams"
await _request_with_retry("GET", url, auth_headers)
async def discover_microsoft365(
source_type: str,
system_config: dict[str, Any],
) -> list[dict[str, Any]]:
"""discover handler发现 Microsoft 365 可用资源(含 Teams 维度)。
handler 不自行换取 tokentoken 由 use_cases 层注入到
``system_config["_resolved_token"]["access_token"]``(见设计文档 §3.7)。
Returns:
资源摘要列表,每项含 ``resource`` / ``paths``(按 auth_mode 选择 ``/me``
或 ``/users/{id}`` 或 ``/teams/{id}``/ ``required_permissions`` / ``methods``。
"""
auth_mode = _resolve_auth_mode(system_config)
resources = _resolve_resources(system_config)
auth_headers = _build_auth_headers(system_config)
# Teams 资源在发现范围内时,调用 Graph API 验证 Teams 权限已授予。
# 权限验证失败抛 AccessDeniedError / AuthError不静默标记 permission_missing。
if _has_teams_resources(resources):
await _verify_teams_permissions(system_config, auth_mode, auth_headers)
results: list[dict[str, Any]] = []
for resource in resources:
# M365 通用资源维度
for item in RESOURCE_CATALOG:
if item["resource"] != resource:
continue
paths_entry = item.get("paths", {})
results.append(
{
"resource": resource,
"paths": paths_entry.get(auth_mode, ""),
"required_permissions": item.get("permissions", {}).get(auth_mode, []),
"methods": ["GET", "POST", "PATCH", "DELETE"],
}
)
break
# Teams 资源维度
if resource in TEAMS_RESOURCES:
for item in TEAMS_RESOURCE_CATALOG:
if item["resource"] != resource:
continue
paths_entry = item.get("paths", {})
results.append(
{
"resource": resource,
"paths": paths_entry.get(auth_mode, ""),
"required_permissions": get_teams_permissions(resource, auth_mode),
"methods": ["GET", "POST", "PATCH", "DELETE"],
}
)
break
return results
async def preview_microsoft365_tools(
source_type: str,
system_config: dict[str, Any],
) -> list[dict[str, Any]]:
"""preview_tools handler预览将生成的 Tool 列表(不持久化)。
Returns:
工具预览列表,每项为 ``ExternalToolCreateInput`` 兼容的 dict。
"""
auth_headers = _build_auth_headers(system_config)
payload = _build_generator_payload(system_config, auth_headers)
generator = Microsoft365ConfigGenerator()
return await generator.generate(payload)
async def create_microsoft365_tools(
source_type: str,
system_config: dict[str, Any],
) -> GeneratedToolsDraft:
"""create_tools handler生成 Tool 草稿,返回 ``GeneratedToolsDraft``(不直接持久化)。
持久化由 use_cases 层 ``integration_tool_service`` / ``import_service`` 统一完成。
"""
auth_headers = _build_auth_headers(system_config)
payload = _build_generator_payload(system_config, auth_headers)
generator = Microsoft365ConfigGenerator()
tool_configs = await generator.generate(payload)
return GeneratedToolsDraft(tool_configs=tool_configs, override_existing=False)