Files
000-platform/backend/app/services/script_sdk.py
Admin 104487f082
All checks were successful
continuous-integration/drone/push Build is passing
feat: 实现定时任务系统
- 新增 platform_scheduled_tasks, platform_task_logs, platform_script_vars, platform_secrets 数据库表
- 实现 ScriptSDK 提供 AI/通知/DB/HTTP/变量存储/参数获取等功能
- 实现安全的脚本执行器,支持沙箱环境和禁止危险操作
- 实现 APScheduler 调度服务,支持简单时间点和 CRON 表达式
- 新增定时任务 API 路由,包含 CRUD、执行、日志、密钥管理
- 新增定时任务前端页面,支持脚本编辑、测试运行、日志查看
2026-01-28 16:38:19 +08:00

480 lines
16 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.
"""脚本执行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