- 扩展 ToolConfig 配置类型,新增 external_api 类型
- 实现接口注册表,包含 90+ 睿美云开放接口定义
- 实现 TPOS SHA256WithRSA 签名鉴权
- 实现睿美云 API 客户端,支持多租户配置
- 新增代理路由 /api/ruimeiyun/call/{api_name}
- 支持接口权限控制和健康检查
This commit is contained in:
325
backend/app/services/ruimeiyun/client.py
Normal file
325
backend/app/services/ruimeiyun/client.py
Normal 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
|
||||
Reference in New Issue
Block a user