All checks were successful
continuous-integration/drone/push Build is passing
- 扩展 ToolConfig 配置类型,新增 external_api 类型
- 实现接口注册表,包含 90+ 睿美云开放接口定义
- 实现 TPOS SHA256WithRSA 签名鉴权
- 实现睿美云 API 客户端,支持多租户配置
- 新增代理路由 /api/ruimeiyun/call/{api_name}
- 支持接口权限控制和健康检查
326 lines
11 KiB
Python
326 lines
11 KiB
Python
"""
|
||
睿美云 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
|