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

341 lines
14 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"]
token_type = token_info.get("token_type", "Bearer")
headers = dict(_GRAPH_HEADERS)
headers["Authorization"] = f"{token_type} {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
if last_response is None:
raise ExecutionError("Microsoft Graph 请求未获得响应")
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_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)