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,10 @@
"""
睿美云对接服务
提供睿美云开放接口的代理调用能力,支持多租户配置。
"""
from .client import RuimeiyunClient
from .registry import RUIMEIYUN_APIS, get_api_definition
__all__ = ["RuimeiyunClient", "RUIMEIYUN_APIS", "get_api_definition"]

View File

@@ -0,0 +1,124 @@
"""
睿美云 TPOS 鉴权
实现睿美云开放接口的身份验证机制:
- tpos-timestamp: 请求时间戳(秒级)
- tpos-account: 账号
- tpos-nonce-str: 随机字符串
- tpos-sign: SHA256WithRSA 签名
签名算法:
1. 组合待签名字符串: {timestamp}&{nonce_str}
2. 使用私钥进行 SHA256WithRSA 签名
3. Base64 编码签名结果
"""
import time
import uuid
import base64
import logging
from typing import Dict
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import padding
from cryptography.hazmat.backends import default_backend
logger = logging.getLogger(__name__)
class TposAuthError(Exception):
"""TPOS 鉴权错误"""
pass
def build_tpos_headers(account: str, private_key_pem: str) -> Dict[str, str]:
"""
构建 TPOS 鉴权请求头
Args:
account: TPOS 账号(由睿美云提供)
private_key_pem: RSA 私钥PEM 格式)
Returns:
包含鉴权信息的请求头字典
Raises:
TposAuthError: 签名失败时抛出
"""
try:
# 1. 生成时间戳和随机字符串
timestamp = str(int(time.time()))
nonce_str = uuid.uuid4().hex
# 2. 组合待签名字符串
sign_content = f"{timestamp}&{nonce_str}"
# 3. 加载私钥
private_key = serialization.load_pem_private_key(
private_key_pem.encode('utf-8'),
password=None,
backend=default_backend()
)
# 4. SHA256WithRSA 签名
signature = private_key.sign(
sign_content.encode('utf-8'),
padding.PKCS1v15(),
hashes.SHA256()
)
# 5. Base64 编码
sign_base64 = base64.b64encode(signature).decode('utf-8')
return {
"tpos-timestamp": timestamp,
"tpos-account": account,
"tpos-nonce-str": nonce_str,
"tpos-sign": sign_base64
}
except Exception as e:
logger.error(f"TPOS 签名失败: {e}")
raise TposAuthError(f"签名失败: {str(e)}")
def validate_private_key(private_key_pem: str) -> bool:
"""
验证私钥格式是否正确
Args:
private_key_pem: RSA 私钥PEM 格式)
Returns:
True 如果私钥有效,否则 False
"""
try:
serialization.load_pem_private_key(
private_key_pem.encode('utf-8'),
password=None,
backend=default_backend()
)
return True
except Exception as e:
logger.warning(f"私钥验证失败: {e}")
return False
def mask_private_key(private_key_pem: str, show_chars: int = 50) -> str:
"""
对私钥进行脱敏处理,用于日志显示
Args:
private_key_pem: RSA 私钥PEM 格式)
show_chars: 显示的字符数
Returns:
脱敏后的字符串
"""
if not private_key_pem:
return ""
if len(private_key_pem) <= show_chars * 2:
return "****"
return f"{private_key_pem[:show_chars]}...****...{private_key_pem[-show_chars:]}"

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

View File

@@ -0,0 +1,885 @@
"""
睿美云接口注册表
定义所有可用的睿美云开放接口,包括:
- 接口路径
- 请求方法
- 参数说明
- 接口分组
接口命名规则: {模块}.{操作}
例如: customer.search, order.list, treat.page
"""
from typing import Dict, Any, Optional, List
from dataclasses import dataclass
@dataclass
class ApiDefinition:
"""接口定义"""
method: str # GET / POST
path: str # API 路径
description: str # 接口描述
module: str # 所属模块
params: Optional[List[str]] = None # URL 参数列表
body_required: bool = False # 是否需要请求体
# 睿美云开放接口注册表
RUIMEIYUN_APIS: Dict[str, Dict[str, Any]] = {
# ========================================
# 客户模块 (customer)
# ========================================
"customer.sync": {
"method": "POST",
"path": "/api/v1/tpos/customer/info-sync",
"description": "客户档案新增/编辑",
"module": "customer",
"body_required": True
},
"customer.search": {
"method": "GET",
"path": "/api/v1/tpos/customer/customer-search",
"description": "获取客户信息列表(支持姓名、电话、档案号模糊查询)",
"module": "customer",
"params": ["keyword", "createDateStart", "createDateEnd", "tenantId", "page", "size", "lastCustomerId"]
},
"customer.detail": {
"method": "GET",
"path": "/api/v1/tpos/customer/customer-by-id",
"description": "根据客户ID获取详细信息",
"module": "customer",
"params": ["customerId"]
},
"customer.rebate_list": {
"method": "POST",
"path": "/api/v1/tpos/customer/my-rebate-list",
"description": "获取客户返利列表",
"module": "customer",
"body_required": True
},
"customer.rebate_detail": {
"method": "GET",
"path": "/api/v1/tpos/customer/my-rebate-detail",
"description": "获取返利详情",
"module": "customer",
"params": ["rebateId"]
},
"customer.clue_list": {
"method": "POST",
"path": "/api/v1/tpos/customer/customer-clue-list",
"description": "获取客户线索列表",
"module": "customer",
"body_required": True
},
"customer.label_list": {
"method": "GET",
"path": "/api/v1/tpos/customer/customer-label-list",
"description": "获取客户标签列表",
"module": "customer",
"params": ["tenantId"]
},
"customer.gold_list": {
"method": "POST",
"path": "/api/v1/tpos/customer/customer-gold-list",
"description": "获取金卡客户列表",
"module": "customer",
"body_required": True
},
"customer.plan_list": {
"method": "GET",
"path": "/api/v1/tpos/customer/get-all-plan",
"description": "获取所有客户计划",
"module": "customer",
"params": ["tenantId"]
},
"customer.transfer_pool": {
"method": "POST",
"path": "/api/v1/tpos/customer/transfer-pool",
"description": "客户池转移",
"module": "customer",
"body_required": True
},
"customer.pool_info": {
"method": "GET",
"path": "/api/v1/tpos/customer/get-pool-info",
"description": "获取客户池信息",
"module": "customer",
"params": ["customerId"]
},
"customer.qw_info": {
"method": "GET",
"path": "/api/v1/tpos/customer/customer-qw-info",
"description": "获取客户企微信息",
"module": "customer",
"params": ["customerId"]
},
"customer.sign_search": {
"method": "POST",
"path": "/api/v1/tpos/customer/customer-sign-search",
"description": "客户签到搜索",
"module": "customer",
"body_required": True
},
# ========================================
# 门店模块 (tenant)
# ========================================
"tenant.list": {
"method": "GET",
"path": "/api/v1/tpos/common/tenantList",
"description": "获取门店信息列表",
"module": "tenant",
"params": []
},
# ========================================
# 回访模块 (visit)
# ========================================
"visit.type_list": {
"method": "GET",
"path": "/api/v1/tpos/visit/get-visit-type",
"description": "获取回访类型列表",
"module": "visit",
"params": ["tenantId"]
},
"visit.way_type_list": {
"method": "GET",
"path": "/api/v1/tpos/visit/get-visit-way-type",
"description": "获取回访方式类型列表",
"module": "visit",
"params": ["tenantId"]
},
"visit.page": {
"method": "POST",
"path": "/api/v1/tpos/visit/get-visit-page",
"description": "分页获取回访记录",
"module": "visit",
"body_required": True
},
"visit.create": {
"method": "POST",
"path": "/api/v1/tpos/visit/create-visit",
"description": "新增回访记录",
"module": "visit",
"body_required": True
},
"visit.template_type": {
"method": "GET",
"path": "/api/v1/tpos/visit/visit-template-type",
"description": "获取回访模板类型",
"module": "visit",
"params": ["tenantId"]
},
# ========================================
# 报备模块 (preparation)
# ========================================
"preparation.add": {
"method": "POST",
"path": "/api/v1/tpos/preparation/add",
"description": "新增报备",
"module": "preparation",
"body_required": True
},
"preparation.query": {
"method": "POST",
"path": "/api/v1/tpos/preparation/get-preparation",
"description": "查询报备",
"module": "preparation",
"body_required": True
},
# ========================================
# 员工模块 (user)
# ========================================
"user.page": {
"method": "GET",
"path": "/api/v1/tpos/user/get-page",
"description": "分页获取员工列表",
"module": "user",
"params": ["page", "size", "tenantId", "keyword"]
},
"user.dept_tree": {
"method": "GET",
"path": "/api/v1/tpos/basic/dept-tree",
"description": "获取部门树结构",
"module": "user",
"params": ["tenantId"]
},
"user.role_list": {
"method": "GET",
"path": "/api/v1/tpos/role/getRoleList",
"description": "获取角色列表",
"module": "user",
"params": ["tenantId"]
},
# ========================================
# 卡券模块 (coupon)
# ========================================
"coupon.customer_page": {
"method": "GET",
"path": "/api/v1/tpos/coupon/customer-coupon-page",
"description": "分页获取客户卡券",
"module": "coupon",
"params": ["customerId", "page", "size"]
},
"coupon.customer_list": {
"method": "GET",
"path": "/api/v1/tpos/coupon/customer-coupon-list",
"description": "获取客户卡券列表",
"module": "coupon",
"params": ["customerId"]
},
"coupon.use": {
"method": "POST",
"path": "/api/v1/tpos/coupon/use-coupon",
"description": "使用卡券",
"module": "coupon",
"body_required": True
},
"coupon.page": {
"method": "GET",
"path": "/api/v1/tpos/coupon/coupon-page",
"description": "分页获取卡券信息",
"module": "coupon",
"params": ["page", "size", "tenantId"]
},
"coupon.send": {
"method": "POST",
"path": "/api/v1/tpos/coupon/send-coupon",
"description": "发送卡券",
"module": "coupon",
"body_required": True
},
"coupon.gift": {
"method": "POST",
"path": "/api/v1/tpos/coupon/gift-coupon",
"description": "卡券赠送(小程序分享)",
"module": "coupon",
"body_required": True
},
"coupon.receive": {
"method": "POST",
"path": "/api/v1/tpos/coupon/receive-coupon",
"description": "领取卡券(小程序分享)",
"module": "coupon",
"body_required": True
},
# ========================================
# 营销模块 (marketing)
# ========================================
"marketing.appoint_card_page": {
"method": "GET",
"path": "/api/v1/tpos/marketing/appoint-card-page",
"description": "线上预约-名片管理",
"module": "marketing",
"params": ["page", "size", "tenantId"]
},
"marketing.graphic_message_list": {
"method": "GET",
"path": "/api/v1/tpos/marketing/graphic-message-list",
"description": "内容管理-图文消息",
"module": "marketing",
"params": ["tenantId"]
},
# ========================================
# 积分模块 (integral)
# ========================================
"integral.customer": {
"method": "GET",
"path": "/api/v1/tpos/integral/getCusIntegral",
"description": "获取客户积分",
"module": "integral",
"params": ["customerId"]
},
"integral.score_record_page": {
"method": "GET",
"path": "/api/v1/tpos/integral/score-record-page",
"description": "获取客户积分/成长值分页信息",
"module": "integral",
"params": ["customerId", "page", "size"]
},
"integral.growth_upgrade": {
"method": "POST",
"path": "/api/v1/tpos/integral/query-customer-growth-upgrade",
"description": "查询客户成长升级信息",
"module": "integral",
"body_required": True
},
# ========================================
# 订单模块 (order)
# ========================================
"order.billing_page": {
"method": "GET",
"path": "/api/v1/tpos/order/billing-page",
"description": "获取订单信息列表",
"module": "order",
"params": ["customerId", "page", "size", "startDate", "endDate"]
},
"order.payment_detail": {
"method": "GET",
"path": "/api/v1/tpos/order/payment-detail",
"description": "获取费用单详细信息",
"module": "order",
"params": ["billingId"]
},
"order.add_billing": {
"method": "POST",
"path": "/api/v1/tpos/order/add-billing",
"description": "开单",
"module": "order",
"body_required": True
},
"order.add_billing_review": {
"method": "POST",
"path": "/api/v1/tpos/order/add-billing-review",
"description": "开单审核",
"module": "order",
"body_required": True
},
"order.enable_billing": {
"method": "GET",
"path": "/api/v1/tpos/order/enable-billing",
"description": "可操作的订单项",
"module": "order",
"params": ["billingId"]
},
"order.refund": {
"method": "POST",
"path": "/api/v1/tpos/order/refund",
"description": "订单退款",
"module": "order",
"body_required": True
},
"order.gift_project": {
"method": "POST",
"path": "/api/v1/tpos/order/gift-project",
"description": "项目转赠(小程序分享)",
"module": "order",
"body_required": True
},
"order.receive_project": {
"method": "POST",
"path": "/api/v1/tpos/order/receive-project",
"description": "领取赠送项目(小程序分享)",
"module": "order",
"body_required": True
},
"order.equity_card_page": {
"method": "GET",
"path": "/api/v1/tpos/order/billing-equity-card-page",
"description": "获取客户权益卡列表",
"module": "order",
"params": ["customerId", "page", "size"]
},
"order.balance_recharge_page": {
"method": "GET",
"path": "/api/v1/tpos/order/balance-recharge-page",
"description": "储值充值记录",
"module": "order",
"params": ["customerId", "page", "size"]
},
"order.balance_deduction_page": {
"method": "GET",
"path": "/api/v1/tpos/order/balance-deduction-page",
"description": "储值抵扣记录",
"module": "order",
"params": ["customerId", "page", "size"]
},
"order.balance_refund_page": {
"method": "GET",
"path": "/api/v1/tpos/order/balance-refund-page",
"description": "储值退款记录",
"module": "order",
"params": ["customerId", "page", "size"]
},
"order.balance_transfer_page": {
"method": "GET",
"path": "/api/v1/tpos/order/balance-transfer-page",
"description": "储值转赠记录",
"module": "order",
"params": ["customerId", "page", "size"]
},
"order.integral_mall_page": {
"method": "GET",
"path": "/api/v1/tpos/order/integral-mall-exchange-page",
"description": "获取积分兑换订单信息列表",
"module": "order",
"params": ["customerId", "page", "size"]
},
"order.add_external": {
"method": "POST",
"path": "/api/v1/tpos/order/add-order-external",
"description": "外部订单创建",
"module": "order",
"body_required": True
},
"order.refund_external": {
"method": "POST",
"path": "/api/v1/tpos/order/refund-order-external",
"description": "外部订单退款",
"module": "order",
"body_required": True
},
"order.customer_billing_list": {
"method": "POST",
"path": "/api/v1/tpos/order/get-customer-billing-list",
"description": "获取客户订单列表",
"module": "order",
"body_required": True
},
"order.cashier_record_list": {
"method": "POST",
"path": "/api/v1/tpos/order/get-cashierRecord-list",
"description": "获取收银记录列表",
"module": "order",
"body_required": True
},
# ========================================
# 治疗模块 (treat)
# ========================================
"treat.untreated_page": {
"method": "GET",
"path": "/api/v1/tpos/treat/untreated-page",
"description": "查询客户未治疗记录",
"module": "treat",
"params": ["customerId", "page", "size"]
},
"treat.already_treated_page": {
"method": "GET",
"path": "/api/v1/tpos/treat/already-treated-page",
"description": "查询客户已治疗记录",
"module": "treat",
"params": ["customerId", "page", "size"]
},
"treat.page_review": {
"method": "GET",
"path": "/api/v1/tpos/treat/treated-page-review",
"description": "分页获取治疗数据",
"module": "treat",
"params": ["page", "size", "tenantId"]
},
"treat.operating_room_list": {
"method": "GET",
"path": "/api/v1/tpos/treat/get-operating-room-list",
"description": "获取治疗时查询的手术间信息",
"module": "treat",
"params": ["tenantId"]
},
"treat.begin_info": {
"method": "GET",
"path": "/api/v1/tpos/treat/get-begin-treat-info",
"description": "获取治疗中和已治疗的数据",
"module": "treat",
"params": ["treatId"]
},
"treat.deduct_verify": {
"method": "POST",
"path": "/api/v1/tpos/treat/treat-deduct-verify",
"description": "进行核销和划扣",
"module": "treat",
"body_required": True
},
"treat.cancel_deduct": {
"method": "POST",
"path": "/api/v1/tpos/treat/cancel-deduct",
"description": "取消划扣",
"module": "treat",
"body_required": True
},
"treat.cancel_verify": {
"method": "POST",
"path": "/api/v1/tpos/treat/cancel-verify",
"description": "取消核销",
"module": "treat",
"body_required": True
},
"treat.deduct_verify_detail": {
"method": "GET",
"path": "/api/v1/tpos/treat/get-treated-deduct-and-verify-detail",
"description": "已治疗的核销和划扣详情信息",
"module": "treat",
"params": ["treatId"]
},
"treat.roles": {
"method": "GET",
"path": "/api/v1/tpos/treat/get-treatment-roles",
"description": "获取所有的治疗岗位列表",
"module": "treat",
"params": ["tenantId"]
},
"treat.scrm_list": {
"method": "POST",
"path": "/api/v1/tpos/treat/scrmTreatList",
"description": "小程序-我的治疗(新版)",
"module": "treat",
"body_required": True
},
# ========================================
# 照片模块 (photo)
# ========================================
"photo.add": {
"method": "POST",
"path": "/api/v1/tpos/common/addPhoto",
"description": "外部七牛照片转存至睿美云",
"module": "photo",
"body_required": True
},
"photo.add_open": {
"method": "POST",
"path": "/api/v1/tpos/common/addPhotoOpen",
"description": "外部照片路径转存至睿美云",
"module": "photo",
"body_required": True
},
"photo.upload": {
"method": "POST",
"path": "/api/v1/tpos/common/upload_customer_photo",
"description": "上传照片到睿美云",
"module": "photo",
"body_required": True
},
"photo.page": {
"method": "GET",
"path": "/api/v1/tpos/common/photoPage",
"description": "通过客户id分页查询照片信息",
"module": "photo",
"params": ["customerId", "page", "size"]
},
"photo.skin_update": {
"method": "POST",
"path": "/api/v1/tpos/skin_image/update_skin_file",
"description": "皮肤检测类图片上传",
"module": "photo",
"body_required": True
},
# ========================================
# 基础数据模块 (basic)
# ========================================
"basic.project_page": {
"method": "GET",
"path": "/api/v1/tpos/basic/project-page",
"description": "分页获取项目列表",
"module": "basic",
"params": ["page", "size", "tenantId", "keyword"]
},
"basic.project_type_tree": {
"method": "GET",
"path": "/api/v1/tpos/basic/project-type-tree",
"description": "获取项目分类树",
"module": "basic",
"params": ["tenantId"]
},
"basic.project_detail": {
"method": "GET",
"path": "/api/v1/tpos/basic/project-detail",
"description": "获取项目详情",
"module": "basic",
"params": ["projectId"]
},
"basic.package_page": {
"method": "GET",
"path": "/api/v1/tpos/basic/package-page",
"description": "分页获取套餐列表",
"module": "basic",
"params": ["page", "size", "tenantId"]
},
"basic.package_type_tree": {
"method": "GET",
"path": "/api/v1/tpos/basic/package-type-tree",
"description": "获取套餐分类树",
"module": "basic",
"params": ["tenantId"]
},
"basic.package_detail": {
"method": "GET",
"path": "/api/v1/tpos/basic/package-detail",
"description": "获取套餐详情",
"module": "basic",
"params": ["packageId"]
},
"basic.annual_card_page": {
"method": "GET",
"path": "/api/v1/tpos/basic/annual-card-page",
"description": "分页获取年卡列表",
"module": "basic",
"params": ["page", "size", "tenantId"]
},
"basic.annual_card_type_tree": {
"method": "GET",
"path": "/api/v1/tpos/basic/annual-card-type-tree",
"description": "获取年卡分类树",
"module": "basic",
"params": ["tenantId"]
},
"basic.annual_card_detail": {
"method": "GET",
"path": "/api/v1/tpos/basic/annual-card-detail",
"description": "获取年卡详情",
"module": "basic",
"params": ["cardId"]
},
"basic.time_card_page": {
"method": "GET",
"path": "/api/v1/tpos/basic/time-card-page",
"description": "分页获取次卡列表",
"module": "basic",
"params": ["page", "size", "tenantId"]
},
"basic.time_card_type_tree": {
"method": "GET",
"path": "/api/v1/tpos/basic/time-card-type-tree",
"description": "获取次卡分类树",
"module": "basic",
"params": ["tenantId"]
},
"basic.time_card_detail": {
"method": "GET",
"path": "/api/v1/tpos/basic/time-card-detail",
"description": "获取次卡详情",
"module": "basic",
"params": ["cardId"]
},
# ========================================
# 预约模块 (cusbespeak)
# ========================================
"appointment.add": {
"method": "POST",
"path": "/api/v1/tpos/cusbespeak/add",
"description": "新增预约",
"module": "appointment",
"body_required": True
},
"appointment.update": {
"method": "POST",
"path": "/api/v1/tpos/cusbespeak/update",
"description": "修改预约",
"module": "appointment",
"body_required": True
},
"appointment.confirm": {
"method": "POST",
"path": "/api/v1/tpos/cusbespeak/confirm",
"description": "确认预约",
"module": "appointment",
"body_required": True
},
"appointment.cancel": {
"method": "POST",
"path": "/api/v1/tpos/cusbespeak/cancel",
"description": "取消预约",
"module": "appointment",
"body_required": True
},
"appointment.page": {
"method": "POST",
"path": "/api/v1/tpos/cusbespeak/page",
"description": "预约分页查询",
"module": "appointment",
"body_required": True
},
"appointment.doctor_list": {
"method": "GET",
"path": "/api/v1/tpos/cusbespeak/doctor-list",
"description": "获取可选择的预约医生",
"module": "appointment",
"params": ["tenantId"]
},
"appointment.schedule": {
"method": "GET",
"path": "/api/v1/tpos/cusbespeak/schedule",
"description": "查询预约专家排班",
"module": "appointment",
"params": ["doctorId", "date"]
},
# ========================================
# 渠道模块 (channel)
# ========================================
"channel.type_select": {
"method": "GET",
"path": "/api/v1/tpos/channel/type-select",
"description": "整合渠道类型选择(建档,报备)",
"module": "channel",
"params": ["tenantId"]
},
"channel.list_by_type": {
"method": "GET",
"path": "/api/v1/tpos/channel/list-by-type",
"description": "通过渠道类型获取渠道列表",
"module": "channel",
"params": ["typeId", "tenantId"]
},
"channel.info": {
"method": "GET",
"path": "/api/v1/tpos/channel/info",
"description": "查询渠道信息",
"module": "channel",
"params": ["channelId"]
},
"channel.media_info": {
"method": "GET",
"path": "/api/v1/tpos/channel/media-info",
"description": "查询运营媒体信息",
"module": "channel",
"params": ["mediaId"]
},
# ========================================
# 接待模块 (reception)
# ========================================
"reception.triage_list": {
"method": "GET",
"path": "/api/v1/tpos/reception/triage-list",
"description": "可用的接待分诊人列表",
"module": "reception",
"params": ["tenantId"]
},
"reception.add": {
"method": "POST",
"path": "/api/v1/tpos/reception/add",
"description": "新增接待",
"module": "reception",
"body_required": True
},
"reception.query": {
"method": "GET",
"path": "/api/v1/tpos/reception/query",
"description": "查询客户接待信息",
"module": "reception",
"params": ["customerId"]
},
"reception.sign_init": {
"method": "GET",
"path": "/api/v1/tpos/reception/sign-init",
"description": "客户扫码签到初始化数据(小程序)",
"module": "reception",
"params": ["tenantId"]
},
"reception.sign": {
"method": "POST",
"path": "/api/v1/tpos/reception/sign",
"description": "客户扫码签到(小程序)",
"module": "reception",
"body_required": True
},
# ========================================
# 咨询模块 (consult)
# ========================================
"consult.add": {
"method": "POST",
"path": "/api/v1/tpos/consult/add",
"description": "新增咨询",
"module": "consult",
"body_required": True
},
"consult.update": {
"method": "POST",
"path": "/api/v1/tpos/consult/update",
"description": "修改咨询",
"module": "consult",
"body_required": True
},
# ========================================
# 病历模块 (medical_record)
# ========================================
"medical_record.add": {
"method": "POST",
"path": "/api/v1/tpos/medical-record/add",
"description": "新增病历",
"module": "medical_record",
"body_required": True
},
"medical_record.update": {
"method": "POST",
"path": "/api/v1/tpos/medical-record/update",
"description": "修改病历",
"module": "medical_record",
"body_required": True
},
"medical_record.delete": {
"method": "POST",
"path": "/api/v1/tpos/medical-record/delete",
"description": "删除病历",
"module": "medical_record",
"body_required": True
},
}
def get_api_definition(api_name: str) -> Optional[Dict[str, Any]]:
"""
获取接口定义
Args:
api_name: 接口名称,如 customer.search
Returns:
接口定义字典,不存在则返回 None
"""
return RUIMEIYUN_APIS.get(api_name)
def get_api_list_by_module(module: str) -> List[Dict[str, Any]]:
"""
按模块获取接口列表
Args:
module: 模块名称,如 customer, order
Returns:
该模块下的接口列表
"""
result = []
for name, definition in RUIMEIYUN_APIS.items():
if definition.get("module") == module:
result.append({"name": name, **definition})
return result
def get_all_modules() -> List[str]:
"""获取所有模块名称"""
modules = set()
for definition in RUIMEIYUN_APIS.values():
if "module" in definition:
modules.add(definition["module"])
return sorted(list(modules))
def get_api_summary() -> Dict[str, int]:
"""获取接口统计"""
summary = {}
for definition in RUIMEIYUN_APIS.values():
module = definition.get("module", "unknown")
summary[module] = summary.get(module, 0) + 1
return summary

File diff suppressed because it is too large Load Diff

View File

@@ -1,285 +1,285 @@
"""脚本执行器 - 安全执行Python脚本"""
import sys
import traceback
from io import StringIO
from typing import Any, Dict, Optional, Tuple
from datetime import datetime
from sqlalchemy.orm import Session
from .script_sdk import ScriptSDK
# 禁止导入的模块
FORBIDDEN_MODULES = {
'os', 'subprocess', 'shutil', 'pathlib',
'socket', 'ftplib', 'telnetlib', 'smtplib',
'pickle', 'shelve', 'marshal',
'ctypes', 'multiprocessing',
'__builtins__', 'builtins',
'importlib', 'imp',
'code', 'codeop', 'compile',
}
# 允许的内置函数
ALLOWED_BUILTINS = {
'abs', 'all', 'any', 'ascii', 'bin', 'bool', 'bytearray', 'bytes',
'callable', 'chr', 'complex', 'dict', 'dir', 'divmod', 'enumerate',
'filter', 'float', 'format', 'frozenset', 'getattr', 'hasattr', 'hash',
'hex', 'id', 'int', 'isinstance', 'issubclass', 'iter', 'len', 'list',
'map', 'max', 'min', 'next', 'object', 'oct', 'ord', 'pow', 'print',
'range', 'repr', 'reversed', 'round', 'set', 'setattr', 'slice',
'sorted', 'str', 'sum', 'tuple', 'type', 'vars', 'zip',
'True', 'False', 'None',
'Exception', 'BaseException', 'ValueError', 'TypeError', 'KeyError',
'IndexError', 'AttributeError', 'RuntimeError', 'StopIteration',
}
class ScriptExecutor:
"""脚本执行器"""
def __init__(self, db: Session):
self.db = db
def execute(
self,
script_content: str,
task_id: int,
tenant_id: Optional[str] = None,
trace_id: Optional[str] = None,
params: Optional[Dict[str, Any]] = None,
timeout: int = 300
) -> Tuple[bool, str, str, Optional[Dict]]:
"""执行脚本
Args:
script_content: Python脚本内容
task_id: 任务ID
tenant_id: 租户ID
trace_id: 追踪ID
params: 输入参数
timeout: 超时秒数
Returns:
(success, output, error, result)
result: 脚本返回值 {'content': '...', 'title': '...'}
"""
# 创建SDK实例
sdk = ScriptSDK(
db=self.db,
task_id=task_id,
tenant_id=tenant_id,
trace_id=trace_id,
params=params or {}
)
# 检查脚本安全性
check_result = self._check_script_safety(script_content)
if check_result:
return False, '', f"脚本安全检查失败: {check_result}", None
# 准备执行环境
safe_globals = self._create_safe_globals(sdk)
# 捕获输出
old_stdout = sys.stdout
old_stderr = sys.stderr
stdout_capture = StringIO()
stderr_capture = StringIO()
try:
sys.stdout = stdout_capture
sys.stderr = stderr_capture
# 编译并执行脚本
compiled = compile(script_content, '<script>', 'exec')
exec(compiled, safe_globals)
# 获取输出
stdout_output = stdout_capture.getvalue()
sdk_output = sdk.get_output()
# 合并输出
output = '\n'.join(filter(None, [sdk_output, stdout_output]))
# 获取脚本返回值(通过 __result__ 变量)
result = safe_globals.get('__result__')
if result is None and 'result' in safe_globals:
result = safe_globals.get('result')
# 如果返回的是字符串,包装成字典
if isinstance(result, str):
result = {'content': result}
elif result is not None and not isinstance(result, dict):
result = {'content': str(result)}
return True, output, '', result
except Exception as e:
error_msg = f"{type(e).__name__}: {str(e)}\n{traceback.format_exc()}"
return False, sdk.get_output(), error_msg, None
finally:
sys.stdout = old_stdout
sys.stderr = old_stderr
def _check_script_safety(self, script_content: str) -> Optional[str]:
"""检查脚本安全性
Returns:
错误消息如果安全则返回None
"""
# 检查危险导入
import_patterns = [
'import os', 'from os',
'import subprocess', 'from subprocess',
'import shutil', 'from shutil',
'import socket', 'from socket',
'__import__',
'eval(', 'exec(',
'compile(',
'open(', # 禁止文件操作
]
script_lower = script_content.lower()
for pattern in import_patterns:
if pattern.lower() in script_lower:
return f"禁止使用: {pattern}"
return None
def _create_safe_globals(self, sdk: ScriptSDK) -> Dict[str, Any]:
"""创建安全的执行环境"""
import json
import re
import math
import random
import hashlib
import base64
import time
import collections
from datetime import datetime, date, timedelta
from urllib.parse import urlencode, quote, unquote
# 允许导入的模块白名单
ALLOWED_MODULES = {
'json': json,
're': re,
'math': math,
'random': random,
'hashlib': hashlib,
'base64': base64,
'time': time,
'datetime': __import__('datetime'),
'collections': collections,
}
def safe_import(name, globals=None, locals=None, fromlist=(), level=0):
"""受限的 import 函数"""
if name in ALLOWED_MODULES:
return ALLOWED_MODULES[name]
raise ImportError(f"不允许导入模块: {name}。已内置可用: {', '.join(ALLOWED_MODULES.keys())}")
# 安全的内置函数
safe_builtins = {name: getattr(__builtins__, name, None)
for name in ALLOWED_BUILTINS
if hasattr(__builtins__, name) or name in dir(__builtins__)}
# 如果 __builtins__ 是字典
if isinstance(__builtins__, dict):
safe_builtins = {name: __builtins__.get(name)
for name in ALLOWED_BUILTINS
if name in __builtins__}
# 添加受限的 __import__
safe_builtins['__import__'] = safe_import
# 添加常用异常
safe_builtins['Exception'] = Exception
safe_builtins['ValueError'] = ValueError
safe_builtins['TypeError'] = TypeError
safe_builtins['KeyError'] = KeyError
safe_builtins['IndexError'] = IndexError
safe_builtins['ImportError'] = ImportError
return {
'__builtins__': safe_builtins,
'__name__': '__main__',
# SDK函数全局可用
'log': sdk.log,
'print': sdk.print,
'ai': sdk.ai,
'dingtalk': sdk.dingtalk,
'wecom': sdk.wecom,
'http_get': sdk.http_get,
'http_post': sdk.http_post,
'db_query': sdk.db_query,
'get_var': sdk.get_var,
'set_var': sdk.set_var,
'del_var': sdk.del_var,
'get_param': sdk.get_param,
'get_params': sdk.get_params,
'get_tenants': sdk.get_tenants,
'get_tenant_config': sdk.get_tenant_config,
'get_all_tenant_configs': sdk.get_all_tenant_configs,
'get_secret': sdk.get_secret,
# 当前上下文
'task_id': sdk.task_id,
'tenant_id': sdk.tenant_id,
'trace_id': sdk.trace_id,
# 安全的标准库
'json': json,
're': re,
'math': math,
'random': random,
'hashlib': hashlib,
'base64': base64,
'datetime': datetime,
'date': date,
'timedelta': timedelta,
'time': time,
'urlencode': urlencode,
'quote': quote,
'unquote': unquote,
}
def test_script(
self,
script_content: str,
task_id: int = 0,
tenant_id: Optional[str] = None,
params: Optional[Dict[str, Any]] = None
) -> Dict[str, Any]:
"""测试脚本(用于调试)
Returns:
{
"success": bool,
"output": str,
"error": str,
"duration_ms": int,
"result": dict
}
"""
start_time = datetime.now()
success, output, error, result = self.execute(
script_content=script_content,
task_id=task_id,
tenant_id=tenant_id,
trace_id=f"test-{start_time.timestamp()}",
params=params
)
duration_ms = int((datetime.now() - start_time).total_seconds() * 1000)
return {
"success": success,
"output": output,
"error": error,
"duration_ms": duration_ms,
"result": result
}
"""脚本执行器 - 安全执行Python脚本"""
import sys
import traceback
from io import StringIO
from typing import Any, Dict, Optional, Tuple
from datetime import datetime
from sqlalchemy.orm import Session
from .script_sdk import ScriptSDK
# 禁止导入的模块
FORBIDDEN_MODULES = {
'os', 'subprocess', 'shutil', 'pathlib',
'socket', 'ftplib', 'telnetlib', 'smtplib',
'pickle', 'shelve', 'marshal',
'ctypes', 'multiprocessing',
'__builtins__', 'builtins',
'importlib', 'imp',
'code', 'codeop', 'compile',
}
# 允许的内置函数
ALLOWED_BUILTINS = {
'abs', 'all', 'any', 'ascii', 'bin', 'bool', 'bytearray', 'bytes',
'callable', 'chr', 'complex', 'dict', 'dir', 'divmod', 'enumerate',
'filter', 'float', 'format', 'frozenset', 'getattr', 'hasattr', 'hash',
'hex', 'id', 'int', 'isinstance', 'issubclass', 'iter', 'len', 'list',
'map', 'max', 'min', 'next', 'object', 'oct', 'ord', 'pow', 'print',
'range', 'repr', 'reversed', 'round', 'set', 'setattr', 'slice',
'sorted', 'str', 'sum', 'tuple', 'type', 'vars', 'zip',
'True', 'False', 'None',
'Exception', 'BaseException', 'ValueError', 'TypeError', 'KeyError',
'IndexError', 'AttributeError', 'RuntimeError', 'StopIteration',
}
class ScriptExecutor:
"""脚本执行器"""
def __init__(self, db: Session):
self.db = db
def execute(
self,
script_content: str,
task_id: int,
tenant_id: Optional[str] = None,
trace_id: Optional[str] = None,
params: Optional[Dict[str, Any]] = None,
timeout: int = 300
) -> Tuple[bool, str, str, Optional[Dict]]:
"""执行脚本
Args:
script_content: Python脚本内容
task_id: 任务ID
tenant_id: 租户ID
trace_id: 追踪ID
params: 输入参数
timeout: 超时秒数
Returns:
(success, output, error, result)
result: 脚本返回值 {'content': '...', 'title': '...'}
"""
# 创建SDK实例
sdk = ScriptSDK(
db=self.db,
task_id=task_id,
tenant_id=tenant_id,
trace_id=trace_id,
params=params or {}
)
# 检查脚本安全性
check_result = self._check_script_safety(script_content)
if check_result:
return False, '', f"脚本安全检查失败: {check_result}", None
# 准备执行环境
safe_globals = self._create_safe_globals(sdk)
# 捕获输出
old_stdout = sys.stdout
old_stderr = sys.stderr
stdout_capture = StringIO()
stderr_capture = StringIO()
try:
sys.stdout = stdout_capture
sys.stderr = stderr_capture
# 编译并执行脚本
compiled = compile(script_content, '<script>', 'exec')
exec(compiled, safe_globals)
# 获取输出
stdout_output = stdout_capture.getvalue()
sdk_output = sdk.get_output()
# 合并输出
output = '\n'.join(filter(None, [sdk_output, stdout_output]))
# 获取脚本返回值(通过 __result__ 变量)
result = safe_globals.get('__result__')
if result is None and 'result' in safe_globals:
result = safe_globals.get('result')
# 如果返回的是字符串,包装成字典
if isinstance(result, str):
result = {'content': result}
elif result is not None and not isinstance(result, dict):
result = {'content': str(result)}
return True, output, '', result
except Exception as e:
error_msg = f"{type(e).__name__}: {str(e)}\n{traceback.format_exc()}"
return False, sdk.get_output(), error_msg, None
finally:
sys.stdout = old_stdout
sys.stderr = old_stderr
def _check_script_safety(self, script_content: str) -> Optional[str]:
"""检查脚本安全性
Returns:
错误消息如果安全则返回None
"""
# 检查危险导入
import_patterns = [
'import os', 'from os',
'import subprocess', 'from subprocess',
'import shutil', 'from shutil',
'import socket', 'from socket',
'__import__',
'eval(', 'exec(',
'compile(',
'open(', # 禁止文件操作
]
script_lower = script_content.lower()
for pattern in import_patterns:
if pattern.lower() in script_lower:
return f"禁止使用: {pattern}"
return None
def _create_safe_globals(self, sdk: ScriptSDK) -> Dict[str, Any]:
"""创建安全的执行环境"""
import json
import re
import math
import random
import hashlib
import base64
import time
import collections
from datetime import datetime, date, timedelta
from urllib.parse import urlencode, quote, unquote
# 允许导入的模块白名单
ALLOWED_MODULES = {
'json': json,
're': re,
'math': math,
'random': random,
'hashlib': hashlib,
'base64': base64,
'time': time,
'datetime': __import__('datetime'),
'collections': collections,
}
def safe_import(name, globals=None, locals=None, fromlist=(), level=0):
"""受限的 import 函数"""
if name in ALLOWED_MODULES:
return ALLOWED_MODULES[name]
raise ImportError(f"不允许导入模块: {name}。已内置可用: {', '.join(ALLOWED_MODULES.keys())}")
# 安全的内置函数
safe_builtins = {name: getattr(__builtins__, name, None)
for name in ALLOWED_BUILTINS
if hasattr(__builtins__, name) or name in dir(__builtins__)}
# 如果 __builtins__ 是字典
if isinstance(__builtins__, dict):
safe_builtins = {name: __builtins__.get(name)
for name in ALLOWED_BUILTINS
if name in __builtins__}
# 添加受限的 __import__
safe_builtins['__import__'] = safe_import
# 添加常用异常
safe_builtins['Exception'] = Exception
safe_builtins['ValueError'] = ValueError
safe_builtins['TypeError'] = TypeError
safe_builtins['KeyError'] = KeyError
safe_builtins['IndexError'] = IndexError
safe_builtins['ImportError'] = ImportError
return {
'__builtins__': safe_builtins,
'__name__': '__main__',
# SDK函数全局可用
'log': sdk.log,
'print': sdk.print,
'ai': sdk.ai,
'dingtalk': sdk.dingtalk,
'wecom': sdk.wecom,
'http_get': sdk.http_get,
'http_post': sdk.http_post,
'db_query': sdk.db_query,
'get_var': sdk.get_var,
'set_var': sdk.set_var,
'del_var': sdk.del_var,
'get_param': sdk.get_param,
'get_params': sdk.get_params,
'get_tenants': sdk.get_tenants,
'get_tenant_config': sdk.get_tenant_config,
'get_all_tenant_configs': sdk.get_all_tenant_configs,
'get_secret': sdk.get_secret,
# 当前上下文
'task_id': sdk.task_id,
'tenant_id': sdk.tenant_id,
'trace_id': sdk.trace_id,
# 安全的标准库
'json': json,
're': re,
'math': math,
'random': random,
'hashlib': hashlib,
'base64': base64,
'datetime': datetime,
'date': date,
'timedelta': timedelta,
'time': time,
'urlencode': urlencode,
'quote': quote,
'unquote': unquote,
}
def test_script(
self,
script_content: str,
task_id: int = 0,
tenant_id: Optional[str] = None,
params: Optional[Dict[str, Any]] = None
) -> Dict[str, Any]:
"""测试脚本(用于调试)
Returns:
{
"success": bool,
"output": str,
"error": str,
"duration_ms": int,
"result": dict
}
"""
start_time = datetime.now()
success, output, error, result = self.execute(
script_content=script_content,
task_id=task_id,
tenant_id=tenant_id,
trace_id=f"test-{start_time.timestamp()}",
params=params
)
duration_ms = int((datetime.now() - start_time).total_seconds() * 1000)
return {
"success": success,
"output": output,
"error": error,
"duration_ms": duration_ms,
"result": result
}

View File

@@ -1,479 +1,479 @@
"""脚本执行SDK - 为Python脚本提供内置功能"""
import json
import os
import httpx
from datetime import datetime
from typing import Any, Dict, List, Optional
from sqlalchemy.orm import Session
class ScriptSDK:
"""脚本SDK - 提供AI、通知、数据库、HTTP、变量存储等功能"""
def __init__(
self,
db: Session,
task_id: int,
tenant_id: Optional[str] = None,
trace_id: Optional[str] = None,
params: Optional[Dict[str, Any]] = None
):
self.db = db
self.task_id = task_id
self.tenant_id = tenant_id
self.trace_id = trace_id
self.params = params or {}
self._logs: List[Dict] = []
self._output: List[str] = []
self._tenants_cache: Dict = {}
# AI 配置
self._ai_base_url = os.getenv('OPENAI_BASE_URL', 'https://api.4sapi.net/v1')
self._ai_api_key = os.getenv('OPENAI_API_KEY', 'sk-9yMCXjRGANbacz20kJY8doSNy6Rf446aYwmgGIuIXQ7DAyBw')
self._ai_model = os.getenv('OPENAI_MODEL', 'gemini-2.5-flash')
# ==================== 参数获取 ====================
def get_param(self, key: str, default: Any = None) -> Any:
"""获取任务参数
Args:
key: 参数名
default: 默认值
Returns:
参数值
"""
return self.params.get(key, default)
def get_params(self) -> Dict[str, Any]:
"""获取所有任务参数
Returns:
所有参数字典
"""
return self.params.copy()
# ==================== 日志 ====================
def log(self, message: str, level: str = 'INFO') -> None:
"""记录日志
Args:
message: 日志内容
level: 日志级别 (INFO, WARN, ERROR)
"""
log_entry = {
'time': datetime.now().isoformat(),
'level': level.upper(),
'message': message
}
self._logs.append(log_entry)
self._output.append(f"[{level.upper()}] {message}")
def print(self, *args, **kwargs) -> None:
"""打印输出兼容print"""
message = ' '.join(str(arg) for arg in args)
self._output.append(message)
def get_logs(self) -> List[Dict]:
"""获取所有日志"""
return self._logs
def get_output(self) -> str:
"""获取所有输出"""
return '\n'.join(self._output)
# ==================== AI 调用 ====================
def ai(
self,
prompt: str,
system: Optional[str] = None,
model: Optional[str] = None,
temperature: float = 0.7,
max_tokens: int = 2000
) -> str:
"""调用AI模型
Args:
prompt: 用户提示词
system: 系统提示词
model: 模型名称默认gemini-2.5-flash
temperature: 温度参数
max_tokens: 最大token数
Returns:
AI响应内容
"""
model = model or self._ai_model
messages = []
if system:
messages.append({"role": "system", "content": system})
messages.append({"role": "user", "content": prompt})
try:
with httpx.Client(timeout=60) as client:
response = client.post(
f"{self._ai_base_url}/chat/completions",
headers={
"Authorization": f"Bearer {self._ai_api_key}",
"Content-Type": "application/json"
},
json={
"model": model,
"messages": messages,
"temperature": temperature,
"max_tokens": max_tokens
}
)
response.raise_for_status()
data = response.json()
content = data['choices'][0]['message']['content']
self.log(f"AI调用成功: {len(content)} 字符")
return content
except Exception as e:
self.log(f"AI调用失败: {str(e)}", 'ERROR')
raise
# ==================== 通知 ====================
def dingtalk(self, webhook: str, content: str, title: Optional[str] = None, at_all: bool = False) -> bool:
"""发送钉钉消息
Args:
webhook: 钉钉机器人webhook地址
content: 消息内容支持Markdown
title: 消息标题
at_all: 是否@所有人
Returns:
是否发送成功
"""
try:
payload = {
"msgtype": "markdown",
"markdown": {
"title": title or "通知",
"text": content + ("\n@所有人" if at_all else "")
},
"at": {"isAtAll": at_all}
}
with httpx.Client(timeout=10) as client:
response = client.post(webhook, json=payload)
response.raise_for_status()
result = response.json()
success = result.get('errcode') == 0
self.log(f"钉钉消息发送{'成功' if success else '失败'}")
return success
except Exception as e:
self.log(f"钉钉消息发送失败: {str(e)}", 'ERROR')
return False
def wecom(self, webhook: str, content: str, msg_type: str = 'markdown') -> bool:
"""发送企业微信消息
Args:
webhook: 企微机器人webhook地址
content: 消息内容
msg_type: 消息类型 (text, markdown)
Returns:
是否发送成功
"""
try:
if msg_type == 'markdown':
payload = {
"msgtype": "markdown",
"markdown": {"content": content}
}
else:
payload = {
"msgtype": "text",
"text": {"content": content}
}
with httpx.Client(timeout=10) as client:
response = client.post(webhook, json=payload)
response.raise_for_status()
result = response.json()
success = result.get('errcode') == 0
self.log(f"企微消息发送{'成功' if success else '失败'}")
return success
except Exception as e:
self.log(f"企微消息发送失败: {str(e)}", 'ERROR')
return False
# ==================== HTTP 请求 ====================
def http_get(self, url: str, headers: Optional[Dict] = None, params: Optional[Dict] = None, timeout: int = 30) -> Dict:
"""发起HTTP GET请求
Returns:
{"status": 200, "data": ..., "text": "..."}
"""
try:
with httpx.Client(timeout=timeout) as client:
response = client.get(url, headers=headers, params=params)
return {
"status": response.status_code,
"data": response.json() if response.headers.get('content-type', '').startswith('application/json') else None,
"text": response.text
}
except Exception as e:
self.log(f"HTTP GET 失败: {str(e)}", 'ERROR')
raise
def http_post(self, url: str, data: Any = None, headers: Optional[Dict] = None, timeout: int = 30) -> Dict:
"""发起HTTP POST请求
Returns:
{"status": 200, "data": ..., "text": "..."}
"""
try:
with httpx.Client(timeout=timeout) as client:
response = client.post(url, json=data, headers=headers)
return {
"status": response.status_code,
"data": response.json() if response.headers.get('content-type', '').startswith('application/json') else None,
"text": response.text
}
except Exception as e:
self.log(f"HTTP POST 失败: {str(e)}", 'ERROR')
raise
# ==================== 数据库查询(只读)====================
def db_query(self, sql: str, params: Optional[Dict] = None) -> List[Dict]:
"""执行只读SQL查询
Args:
sql: SQL语句必须是SELECT
params: 参数字典
Returns:
查询结果列表
"""
sql_upper = sql.strip().upper()
if not sql_upper.startswith('SELECT'):
raise ValueError("只允许执行SELECT查询")
# 禁止危险操作
forbidden = ['INSERT', 'UPDATE', 'DELETE', 'DROP', 'TRUNCATE', 'ALTER', 'CREATE']
for word in forbidden:
if word in sql_upper:
raise ValueError(f"禁止执行 {word} 操作")
try:
from sqlalchemy import text
result = self.db.execute(text(sql), params or {})
columns = result.keys()
rows = [dict(zip(columns, row)) for row in result.fetchall()]
self.log(f"SQL查询返回 {len(rows)} 条记录")
return rows
except Exception as e:
self.log(f"SQL查询失败: {str(e)}", 'ERROR')
raise
# ==================== 变量存储 ====================
def get_var(self, key: str, default: Any = None) -> Any:
"""获取持久化变量
Args:
key: 变量名
default: 默认值
Returns:
变量值
"""
from ..models.scheduled_task import ScriptVar
var = self.db.query(ScriptVar).filter(
ScriptVar.task_id == self.task_id,
ScriptVar.tenant_id == self.tenant_id,
ScriptVar.var_key == key
).first()
if var and var.var_value:
try:
return json.loads(var.var_value)
except:
return var.var_value
return default
def set_var(self, key: str, value: Any) -> None:
"""设置持久化变量
Args:
key: 变量名
value: 变量值会JSON序列化
"""
from ..models.scheduled_task import ScriptVar
var = self.db.query(ScriptVar).filter(
ScriptVar.task_id == self.task_id,
ScriptVar.tenant_id == self.tenant_id,
ScriptVar.var_key == key
).first()
value_json = json.dumps(value, ensure_ascii=False)
if var:
var.var_value = value_json
else:
var = ScriptVar(
task_id=self.task_id,
tenant_id=self.tenant_id,
var_key=key,
var_value=value_json
)
self.db.add(var)
self.db.commit()
self.log(f"变量 {key} 已保存")
def del_var(self, key: str) -> bool:
"""删除持久化变量"""
from ..models.scheduled_task import ScriptVar
result = self.db.query(ScriptVar).filter(
ScriptVar.task_id == self.task_id,
ScriptVar.tenant_id == self.tenant_id,
ScriptVar.var_key == key
).delete()
self.db.commit()
return result > 0
# ==================== 租户配置 ====================
def get_tenants(self, app_code: Optional[str] = None) -> List[Dict]:
"""获取租户列表
Args:
app_code: 可选,按应用代码筛选
Returns:
租户列表 [{"tenant_id": ..., "tenant_name": ...}, ...]
"""
from ..models.tenant import Tenant
from ..models.tenant_app import TenantApp
if app_code:
# 获取订阅了该应用的租户
tenant_ids = self.db.query(TenantApp.tenant_id).filter(
TenantApp.app_code == app_code,
TenantApp.status == 1
).all()
tenant_ids = [t[0] for t in tenant_ids]
tenants = self.db.query(Tenant).filter(
Tenant.code.in_(tenant_ids),
Tenant.status == 'active'
).all()
else:
tenants = self.db.query(Tenant).filter(Tenant.status == 'active').all()
return [{"tenant_id": t.code, "tenant_name": t.name} for t in tenants]
def get_tenant_config(self, tenant_id: str, app_code: str, key: Optional[str] = None) -> Any:
"""获取租户的应用配置
Args:
tenant_id: 租户ID
app_code: 应用代码
key: 配置键(可选,不提供则返回所有配置)
Returns:
配置值或配置字典
"""
from ..models.tenant_app import TenantApp
tenant_app = self.db.query(TenantApp).filter(
TenantApp.tenant_id == tenant_id,
TenantApp.app_code == app_code
).first()
if not tenant_app:
return None if key else {}
# 解析 custom_configs
configs = {}
if hasattr(tenant_app, 'custom_configs') and tenant_app.custom_configs:
try:
configs = json.loads(tenant_app.custom_configs) if isinstance(tenant_app.custom_configs, str) else tenant_app.custom_configs
except:
pass
if key:
return configs.get(key)
return configs
def get_all_tenant_configs(self, app_code: str) -> List[Dict]:
"""获取所有租户的应用配置
Args:
app_code: 应用代码
Returns:
[{"tenant_id": ..., "tenant_name": ..., "configs": {...}}, ...]
"""
from ..models.tenant import Tenant
from ..models.tenant_app import TenantApp
tenant_apps = self.db.query(TenantApp).filter(
TenantApp.app_code == app_code,
TenantApp.status == 1
).all()
result = []
for ta in tenant_apps:
tenant = self.db.query(Tenant).filter(Tenant.code == ta.tenant_id).first()
configs = {}
if hasattr(ta, 'custom_configs') and ta.custom_configs:
try:
configs = json.loads(ta.custom_configs) if isinstance(ta.custom_configs, str) else ta.custom_configs
except:
pass
result.append({
"tenant_id": ta.tenant_id,
"tenant_name": tenant.name if tenant else ta.tenant_id,
"configs": configs
})
return result
# ==================== 密钥管理 ====================
def get_secret(self, key: str) -> Optional[str]:
"""获取密钥(优先租户级,其次全局)
Args:
key: 密钥名
Returns:
密钥值
"""
from ..models.scheduled_task import Secret
# 先查租户级
if self.tenant_id:
secret = self.db.query(Secret).filter(
Secret.tenant_id == self.tenant_id,
Secret.secret_key == key
).first()
if secret:
return secret.secret_value
# 再查全局
secret = self.db.query(Secret).filter(
Secret.tenant_id.is_(None),
Secret.secret_key == key
).first()
return secret.secret_value if secret else None
"""脚本执行SDK - 为Python脚本提供内置功能"""
import json
import os
import httpx
from datetime import datetime
from typing import Any, Dict, List, Optional
from sqlalchemy.orm import Session
class ScriptSDK:
"""脚本SDK - 提供AI、通知、数据库、HTTP、变量存储等功能"""
def __init__(
self,
db: Session,
task_id: int,
tenant_id: Optional[str] = None,
trace_id: Optional[str] = None,
params: Optional[Dict[str, Any]] = None
):
self.db = db
self.task_id = task_id
self.tenant_id = tenant_id
self.trace_id = trace_id
self.params = params or {}
self._logs: List[Dict] = []
self._output: List[str] = []
self._tenants_cache: Dict = {}
# AI 配置
self._ai_base_url = os.getenv('OPENAI_BASE_URL', 'https://api.4sapi.net/v1')
self._ai_api_key = os.getenv('OPENAI_API_KEY', 'sk-9yMCXjRGANbacz20kJY8doSNy6Rf446aYwmgGIuIXQ7DAyBw')
self._ai_model = os.getenv('OPENAI_MODEL', 'gemini-2.5-flash')
# ==================== 参数获取 ====================
def get_param(self, key: str, default: Any = None) -> Any:
"""获取任务参数
Args:
key: 参数名
default: 默认值
Returns:
参数值
"""
return self.params.get(key, default)
def get_params(self) -> Dict[str, Any]:
"""获取所有任务参数
Returns:
所有参数字典
"""
return self.params.copy()
# ==================== 日志 ====================
def log(self, message: str, level: str = 'INFO') -> None:
"""记录日志
Args:
message: 日志内容
level: 日志级别 (INFO, WARN, ERROR)
"""
log_entry = {
'time': datetime.now().isoformat(),
'level': level.upper(),
'message': message
}
self._logs.append(log_entry)
self._output.append(f"[{level.upper()}] {message}")
def print(self, *args, **kwargs) -> None:
"""打印输出兼容print"""
message = ' '.join(str(arg) for arg in args)
self._output.append(message)
def get_logs(self) -> List[Dict]:
"""获取所有日志"""
return self._logs
def get_output(self) -> str:
"""获取所有输出"""
return '\n'.join(self._output)
# ==================== AI 调用 ====================
def ai(
self,
prompt: str,
system: Optional[str] = None,
model: Optional[str] = None,
temperature: float = 0.7,
max_tokens: int = 2000
) -> str:
"""调用AI模型
Args:
prompt: 用户提示词
system: 系统提示词
model: 模型名称默认gemini-2.5-flash
temperature: 温度参数
max_tokens: 最大token数
Returns:
AI响应内容
"""
model = model or self._ai_model
messages = []
if system:
messages.append({"role": "system", "content": system})
messages.append({"role": "user", "content": prompt})
try:
with httpx.Client(timeout=60) as client:
response = client.post(
f"{self._ai_base_url}/chat/completions",
headers={
"Authorization": f"Bearer {self._ai_api_key}",
"Content-Type": "application/json"
},
json={
"model": model,
"messages": messages,
"temperature": temperature,
"max_tokens": max_tokens
}
)
response.raise_for_status()
data = response.json()
content = data['choices'][0]['message']['content']
self.log(f"AI调用成功: {len(content)} 字符")
return content
except Exception as e:
self.log(f"AI调用失败: {str(e)}", 'ERROR')
raise
# ==================== 通知 ====================
def dingtalk(self, webhook: str, content: str, title: Optional[str] = None, at_all: bool = False) -> bool:
"""发送钉钉消息
Args:
webhook: 钉钉机器人webhook地址
content: 消息内容支持Markdown
title: 消息标题
at_all: 是否@所有人
Returns:
是否发送成功
"""
try:
payload = {
"msgtype": "markdown",
"markdown": {
"title": title or "通知",
"text": content + ("\n@所有人" if at_all else "")
},
"at": {"isAtAll": at_all}
}
with httpx.Client(timeout=10) as client:
response = client.post(webhook, json=payload)
response.raise_for_status()
result = response.json()
success = result.get('errcode') == 0
self.log(f"钉钉消息发送{'成功' if success else '失败'}")
return success
except Exception as e:
self.log(f"钉钉消息发送失败: {str(e)}", 'ERROR')
return False
def wecom(self, webhook: str, content: str, msg_type: str = 'markdown') -> bool:
"""发送企业微信消息
Args:
webhook: 企微机器人webhook地址
content: 消息内容
msg_type: 消息类型 (text, markdown)
Returns:
是否发送成功
"""
try:
if msg_type == 'markdown':
payload = {
"msgtype": "markdown",
"markdown": {"content": content}
}
else:
payload = {
"msgtype": "text",
"text": {"content": content}
}
with httpx.Client(timeout=10) as client:
response = client.post(webhook, json=payload)
response.raise_for_status()
result = response.json()
success = result.get('errcode') == 0
self.log(f"企微消息发送{'成功' if success else '失败'}")
return success
except Exception as e:
self.log(f"企微消息发送失败: {str(e)}", 'ERROR')
return False
# ==================== HTTP 请求 ====================
def http_get(self, url: str, headers: Optional[Dict] = None, params: Optional[Dict] = None, timeout: int = 30) -> Dict:
"""发起HTTP GET请求
Returns:
{"status": 200, "data": ..., "text": "..."}
"""
try:
with httpx.Client(timeout=timeout) as client:
response = client.get(url, headers=headers, params=params)
return {
"status": response.status_code,
"data": response.json() if response.headers.get('content-type', '').startswith('application/json') else None,
"text": response.text
}
except Exception as e:
self.log(f"HTTP GET 失败: {str(e)}", 'ERROR')
raise
def http_post(self, url: str, data: Any = None, headers: Optional[Dict] = None, timeout: int = 30) -> Dict:
"""发起HTTP POST请求
Returns:
{"status": 200, "data": ..., "text": "..."}
"""
try:
with httpx.Client(timeout=timeout) as client:
response = client.post(url, json=data, headers=headers)
return {
"status": response.status_code,
"data": response.json() if response.headers.get('content-type', '').startswith('application/json') else None,
"text": response.text
}
except Exception as e:
self.log(f"HTTP POST 失败: {str(e)}", 'ERROR')
raise
# ==================== 数据库查询(只读)====================
def db_query(self, sql: str, params: Optional[Dict] = None) -> List[Dict]:
"""执行只读SQL查询
Args:
sql: SQL语句必须是SELECT
params: 参数字典
Returns:
查询结果列表
"""
sql_upper = sql.strip().upper()
if not sql_upper.startswith('SELECT'):
raise ValueError("只允许执行SELECT查询")
# 禁止危险操作
forbidden = ['INSERT', 'UPDATE', 'DELETE', 'DROP', 'TRUNCATE', 'ALTER', 'CREATE']
for word in forbidden:
if word in sql_upper:
raise ValueError(f"禁止执行 {word} 操作")
try:
from sqlalchemy import text
result = self.db.execute(text(sql), params or {})
columns = result.keys()
rows = [dict(zip(columns, row)) for row in result.fetchall()]
self.log(f"SQL查询返回 {len(rows)} 条记录")
return rows
except Exception as e:
self.log(f"SQL查询失败: {str(e)}", 'ERROR')
raise
# ==================== 变量存储 ====================
def get_var(self, key: str, default: Any = None) -> Any:
"""获取持久化变量
Args:
key: 变量名
default: 默认值
Returns:
变量值
"""
from ..models.scheduled_task import ScriptVar
var = self.db.query(ScriptVar).filter(
ScriptVar.task_id == self.task_id,
ScriptVar.tenant_id == self.tenant_id,
ScriptVar.var_key == key
).first()
if var and var.var_value:
try:
return json.loads(var.var_value)
except:
return var.var_value
return default
def set_var(self, key: str, value: Any) -> None:
"""设置持久化变量
Args:
key: 变量名
value: 变量值会JSON序列化
"""
from ..models.scheduled_task import ScriptVar
var = self.db.query(ScriptVar).filter(
ScriptVar.task_id == self.task_id,
ScriptVar.tenant_id == self.tenant_id,
ScriptVar.var_key == key
).first()
value_json = json.dumps(value, ensure_ascii=False)
if var:
var.var_value = value_json
else:
var = ScriptVar(
task_id=self.task_id,
tenant_id=self.tenant_id,
var_key=key,
var_value=value_json
)
self.db.add(var)
self.db.commit()
self.log(f"变量 {key} 已保存")
def del_var(self, key: str) -> bool:
"""删除持久化变量"""
from ..models.scheduled_task import ScriptVar
result = self.db.query(ScriptVar).filter(
ScriptVar.task_id == self.task_id,
ScriptVar.tenant_id == self.tenant_id,
ScriptVar.var_key == key
).delete()
self.db.commit()
return result > 0
# ==================== 租户配置 ====================
def get_tenants(self, app_code: Optional[str] = None) -> List[Dict]:
"""获取租户列表
Args:
app_code: 可选,按应用代码筛选
Returns:
租户列表 [{"tenant_id": ..., "tenant_name": ...}, ...]
"""
from ..models.tenant import Tenant
from ..models.tenant_app import TenantApp
if app_code:
# 获取订阅了该应用的租户
tenant_ids = self.db.query(TenantApp.tenant_id).filter(
TenantApp.app_code == app_code,
TenantApp.status == 1
).all()
tenant_ids = [t[0] for t in tenant_ids]
tenants = self.db.query(Tenant).filter(
Tenant.code.in_(tenant_ids),
Tenant.status == 'active'
).all()
else:
tenants = self.db.query(Tenant).filter(Tenant.status == 'active').all()
return [{"tenant_id": t.code, "tenant_name": t.name} for t in tenants]
def get_tenant_config(self, tenant_id: str, app_code: str, key: Optional[str] = None) -> Any:
"""获取租户的应用配置
Args:
tenant_id: 租户ID
app_code: 应用代码
key: 配置键(可选,不提供则返回所有配置)
Returns:
配置值或配置字典
"""
from ..models.tenant_app import TenantApp
tenant_app = self.db.query(TenantApp).filter(
TenantApp.tenant_id == tenant_id,
TenantApp.app_code == app_code
).first()
if not tenant_app:
return None if key else {}
# 解析 custom_configs
configs = {}
if hasattr(tenant_app, 'custom_configs') and tenant_app.custom_configs:
try:
configs = json.loads(tenant_app.custom_configs) if isinstance(tenant_app.custom_configs, str) else tenant_app.custom_configs
except:
pass
if key:
return configs.get(key)
return configs
def get_all_tenant_configs(self, app_code: str) -> List[Dict]:
"""获取所有租户的应用配置
Args:
app_code: 应用代码
Returns:
[{"tenant_id": ..., "tenant_name": ..., "configs": {...}}, ...]
"""
from ..models.tenant import Tenant
from ..models.tenant_app import TenantApp
tenant_apps = self.db.query(TenantApp).filter(
TenantApp.app_code == app_code,
TenantApp.status == 1
).all()
result = []
for ta in tenant_apps:
tenant = self.db.query(Tenant).filter(Tenant.code == ta.tenant_id).first()
configs = {}
if hasattr(ta, 'custom_configs') and ta.custom_configs:
try:
configs = json.loads(ta.custom_configs) if isinstance(ta.custom_configs, str) else ta.custom_configs
except:
pass
result.append({
"tenant_id": ta.tenant_id,
"tenant_name": tenant.name if tenant else ta.tenant_id,
"configs": configs
})
return result
# ==================== 密钥管理 ====================
def get_secret(self, key: str) -> Optional[str]:
"""获取密钥(优先租户级,其次全局)
Args:
key: 密钥名
Returns:
密钥值
"""
from ..models.scheduled_task import Secret
# 先查租户级
if self.tenant_id:
secret = self.db.query(Secret).filter(
Secret.tenant_id == self.tenant_id,
Secret.secret_key == key
).first()
if secret:
return secret.secret_value
# 再查全局
secret = self.db.query(Secret).filter(
Secret.tenant_id.is_(None),
Secret.secret_key == key
).first()
return secret.secret_value if secret else None