Files
000-platform/backend/app/services/ruimeiyun/client.py
Admin afcf30b519
All checks were successful
continuous-integration/drone/push Build is passing
feat: 新增睿美云对接模块
- 扩展 ToolConfig 配置类型,新增 external_api 类型
- 实现接口注册表,包含 90+ 睿美云开放接口定义
- 实现 TPOS SHA256WithRSA 签名鉴权
- 实现睿美云 API 客户端,支持多租户配置
- 新增代理路由 /api/ruimeiyun/call/{api_name}
- 支持接口权限控制和健康检查
2026-01-30 17:27:58 +08:00

326 lines
11 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.
"""
睿美云 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