feat: 新增睿美云对接模块
All checks were successful
continuous-integration/drone/push Build is passing

- 扩展 ToolConfig 配置类型,新增 external_api 类型
- 实现接口注册表,包含 90+ 睿美云开放接口定义
- 实现 TPOS SHA256WithRSA 签名鉴权
- 实现睿美云 API 客户端,支持多租户配置
- 新增代理路由 /api/ruimeiyun/call/{api_name}
- 支持接口权限控制和健康检查
This commit is contained in:
2026-01-30 17:27:58 +08:00
parent c1ba17f809
commit afcf30b519
23 changed files with 6290 additions and 4648 deletions

View File

@@ -0,0 +1,325 @@
"""
睿美云 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