""" 睿美云 API 客户端 提供统一的睿美云接口调用能力: - 自动加载租户配置 - 自动构建 TPOS 鉴权头 - 统一错误处理 - 请求日志记录 """ import json import logging from typing import Dict, Any, Optional, List from dataclasses import dataclass import httpx from sqlalchemy.orm import Session from .auth import build_tpos_headers, TposAuthError from .registry import get_api_definition, RUIMEIYUN_APIS from ..crypto import decrypt_value logger = logging.getLogger(__name__) # 请求超时设置 DEFAULT_TIMEOUT = 30.0 # 秒 LONG_TIMEOUT = 60.0 # 长时间操作 @dataclass class RuimeiyunConfig: """睿美云配置""" base_url: str account: str private_key: str allowed_apis: Optional[List[str]] = None @dataclass class RuimeiyunResponse: """睿美云响应""" success: bool data: Optional[Any] = None error: Optional[str] = None status_code: int = 200 raw_response: Optional[Dict] = None class RuimeiyunError(Exception): """睿美云调用错误""" def __init__(self, message: str, status_code: int = 500, response: Any = None): super().__init__(message) self.status_code = status_code self.response = response class RuimeiyunClient: """ 睿美云 API 客户端 使用方式: client = RuimeiyunClient(tenant_id, db) result = await client.call("customer.search", params={"keyword": "13800138000"}) """ def __init__(self, tenant_id: str, db: Session): """ 初始化客户端 Args: tenant_id: 租户ID db: 数据库会话 """ self.tenant_id = tenant_id self.db = db self.config = self._load_config() def _load_config(self) -> RuimeiyunConfig: """从数据库加载租户的睿美云配置""" from ...models.tool_config import ToolConfig # 查询租户的睿美云配置 configs = self.db.query(ToolConfig).filter( ToolConfig.tenant_id == self.tenant_id, ToolConfig.tool_code == "ruimeiyun", ToolConfig.config_type == "external_api", ToolConfig.status == 1 ).all() if not configs: raise RuimeiyunError( f"租户 {self.tenant_id} 未配置睿美云连接信息", status_code=400 ) # 转换为字典 config_dict = {} for c in configs: value = c.config_value # 解密加密字段 if c.is_encrypted and value: try: value = decrypt_value(value) except Exception as e: logger.error(f"解密配置失败: {c.config_key}, {e}") raise RuimeiyunError(f"配置解密失败: {c.config_key}") config_dict[c.config_key] = value # 验证必填配置 required = ["ruimeiyun_base_url", "ruimeiyun_account", "ruimeiyun_private_key"] for key in required: if not config_dict.get(key): raise RuimeiyunError(f"缺少必填配置: {key}", status_code=400) # 解析允许的接口列表 allowed_apis = None if config_dict.get("ruimeiyun_allowed_apis"): try: allowed_apis = json.loads(config_dict["ruimeiyun_allowed_apis"]) except json.JSONDecodeError: logger.warning(f"解析 allowed_apis 失败: {config_dict.get('ruimeiyun_allowed_apis')}") return RuimeiyunConfig( base_url=config_dict["ruimeiyun_base_url"].rstrip("/"), account=config_dict["ruimeiyun_account"], private_key=config_dict["ruimeiyun_private_key"], allowed_apis=allowed_apis ) def _check_permission(self, api_name: str): """检查是否有权限调用该接口""" if self.config.allowed_apis is not None: if api_name not in self.config.allowed_apis: raise RuimeiyunError( f"租户无权调用接口: {api_name}", status_code=403 ) async def call( self, api_name: str, params: Optional[Dict[str, Any]] = None, body: Optional[Dict[str, Any]] = None, timeout: float = DEFAULT_TIMEOUT ) -> RuimeiyunResponse: """ 调用睿美云接口 Args: api_name: 接口名称,如 customer.search params: URL 查询参数 body: 请求体(POST 请求) timeout: 超时时间(秒) Returns: RuimeiyunResponse 对象 Raises: RuimeiyunError: 调用失败时抛出 """ # 1. 获取接口定义 api_def = get_api_definition(api_name) if not api_def: raise RuimeiyunError(f"未知接口: {api_name}", status_code=400) # 2. 检查权限 self._check_permission(api_name) # 3. 构建请求 method = api_def["method"] url = f"{self.config.base_url}{api_def['path']}" # 4. 构建鉴权头 try: auth_headers = build_tpos_headers( self.config.account, self.config.private_key ) except TposAuthError as e: raise RuimeiyunError(str(e), status_code=500) headers = { **auth_headers, "Content-Type": "application/json" } # 5. 发送请求 logger.info(f"调用睿美云接口: {api_name} ({method} {api_def['path']})") try: async with httpx.AsyncClient(timeout=timeout) as client: if method == "GET": response = await client.get(url, params=params, headers=headers) else: response = await client.post(url, params=params, json=body, headers=headers) # 6. 处理响应 status_code = response.status_code try: response_data = response.json() except Exception: response_data = {"raw": response.text} # 睿美云响应格式通常为: {"code": 0, "data": ..., "msg": "success"} if status_code == 200: # 检查业务状态码 code = response_data.get("code") if code == 0 or code == "0" or code is None: return RuimeiyunResponse( success=True, data=response_data.get("data", response_data), status_code=status_code, raw_response=response_data ) else: return RuimeiyunResponse( success=False, error=response_data.get("msg", response_data.get("message", "未知错误")), status_code=status_code, raw_response=response_data ) else: return RuimeiyunResponse( success=False, error=f"HTTP {status_code}: {response_data}", status_code=status_code, raw_response=response_data ) except httpx.TimeoutException: logger.error(f"睿美云接口超时: {api_name}") raise RuimeiyunError(f"接口超时: {api_name}", status_code=504) except httpx.RequestError as e: logger.error(f"睿美云接口请求错误: {api_name}, {e}") raise RuimeiyunError(f"请求错误: {str(e)}", status_code=502) async def call_raw( self, method: str, path: str, params: Optional[Dict[str, Any]] = None, body: Optional[Dict[str, Any]] = None, timeout: float = DEFAULT_TIMEOUT ) -> RuimeiyunResponse: """ 直接调用睿美云接口(不经过注册表) 用于调用未在注册表中定义的接口 Args: method: HTTP 方法 path: API 路径 params: URL 查询参数 body: 请求体 timeout: 超时时间 Returns: RuimeiyunResponse 对象 """ url = f"{self.config.base_url}{path}" try: auth_headers = build_tpos_headers( self.config.account, self.config.private_key ) except TposAuthError as e: raise RuimeiyunError(str(e), status_code=500) headers = { **auth_headers, "Content-Type": "application/json" } logger.info(f"调用睿美云接口(raw): {method} {path}") try: async with httpx.AsyncClient(timeout=timeout) as client: response = await client.request( method=method, url=url, params=params, json=body if method != "GET" else None, headers=headers ) try: response_data = response.json() except Exception: response_data = {"raw": response.text} if response.status_code == 200: code = response_data.get("code") if code == 0 or code == "0" or code is None: return RuimeiyunResponse( success=True, data=response_data.get("data", response_data), status_code=response.status_code, raw_response=response_data ) else: return RuimeiyunResponse( success=False, error=response_data.get("msg", "未知错误"), status_code=response.status_code, raw_response=response_data ) else: return RuimeiyunResponse( success=False, error=f"HTTP {response.status_code}", status_code=response.status_code, raw_response=response_data ) except httpx.TimeoutException: raise RuimeiyunError("接口超时", status_code=504) except httpx.RequestError as e: raise RuimeiyunError(f"请求错误: {str(e)}", status_code=502) @staticmethod def get_available_apis() -> Dict[str, Any]: """获取所有可用的接口列表""" return RUIMEIYUN_APIS