Files
000-platform/backend/app/services/script_sdk.py
Admin 644255891e
Some checks failed
continuous-integration/drone/push Build is failing
feat: 脚本执行平台功能
- 支持 Python 脚本定时执行(类似青龙面板)
- 内置 SDK:AI大模型、钉钉/企微通知、数据库查询、HTTP请求、变量存储
- 安全沙箱执行,禁用危险模块
- 前端脚本编辑器,支持测试执行
- SDK 文档查看
- 日志通过 TraceID 与 platform_logs 关联
2026-01-28 11:45:02 +08:00

444 lines
14 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 logging
from datetime import datetime
from typing import Any, Dict, List, Optional
import httpx
from sqlalchemy import text
from sqlalchemy.orm import Session
from ..database import SessionLocal
logger = logging.getLogger(__name__)
class ScriptSDK:
"""
脚本执行 SDK
提供以下能力:
- AI 大模型调用
- 钉钉/企微通知
- 数据库查询(只读)
- HTTP 请求
- 变量存储(跨执行持久化)
- 日志记录
"""
def __init__(self, tenant_id: str, task_id: int, trace_id: str = None):
self.tenant_id = tenant_id
self.task_id = task_id
self.trace_id = trace_id or f"script_{task_id}_{datetime.now().strftime('%Y%m%d%H%M%S')}"
self._logs: List[str] = []
self._db: Optional[Session] = None
def _get_db(self) -> Session:
"""获取数据库会话"""
if self._db is None:
self._db = SessionLocal()
return self._db
def _close_db(self):
"""关闭数据库会话"""
if self._db:
self._db.close()
self._db = None
# ============ AI 服务 ============
async def ai_chat(
self,
prompt: str,
system: str = None,
model: str = "gemini-2.5-flash",
temperature: float = 0.7,
max_tokens: int = 2000
) -> str:
"""
调用大模型
Args:
prompt: 用户提示词
system: 系统提示词(可选)
model: 模型名称,默认 gemini-2.5-flash
temperature: 温度,默认 0.7
max_tokens: 最大 token 数,默认 2000
Returns:
AI 生成的文本
"""
# 使用 4sapi 作为 AI 服务
api_key = "sk-9yMCXjRGANbacz20kJY8doSNy6Rf446aYwmgGIuIXQ7DAyBw"
base_url = "https://4sapi.com/v1"
messages = []
if system:
messages.append({"role": "system", "content": system})
messages.append({"role": "user", "content": prompt})
try:
async with httpx.AsyncClient(timeout=120.0) as client:
response = await client.post(
f"{base_url}/chat/completions",
headers={
"Authorization": f"Bearer {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 调用成功,模型: {model},响应长度: {len(content)}")
return content
except Exception as e:
self.log(f"AI 调用失败: {str(e)}", level="ERROR")
raise
# ============ 通知服务 ============
async def send_dingtalk(
self,
webhook: str,
content: str,
msg_type: str = "text",
at_mobiles: List[str] = None,
at_all: bool = False
) -> bool:
"""
发送钉钉群消息
Args:
webhook: 钉钉机器人 Webhook URL
content: 消息内容
msg_type: 消息类型text 或 markdown
at_mobiles: @的手机号列表
at_all: 是否@所有人
Returns:
是否发送成功
"""
try:
if msg_type == "text":
data = {
"msgtype": "text",
"text": {"content": content},
"at": {
"atMobiles": at_mobiles or [],
"isAtAll": at_all
}
}
else:
data = {
"msgtype": "markdown",
"markdown": {
"title": content[:20] if len(content) > 20 else content,
"text": content
},
"at": {
"atMobiles": at_mobiles or [],
"isAtAll": at_all
}
}
async with httpx.AsyncClient(timeout=30.0) as client:
response = await client.post(webhook, json=data)
result = response.json()
if result.get("errcode") == 0:
self.log(f"钉钉消息发送成功")
return True
else:
self.log(f"钉钉消息发送失败: {result.get('errmsg')}", level="ERROR")
return False
except Exception as e:
self.log(f"钉钉消息发送异常: {str(e)}", level="ERROR")
return False
async def send_wecom(
self,
webhook: str,
content: str,
msg_type: str = "text"
) -> bool:
"""
发送企业微信群消息
Args:
webhook: 企微机器人 Webhook URL
content: 消息内容
msg_type: 消息类型text 或 markdown
Returns:
是否发送成功
"""
try:
if msg_type == "text":
data = {
"msgtype": "text",
"text": {"content": content}
}
else:
data = {
"msgtype": "markdown",
"markdown": {"content": content}
}
async with httpx.AsyncClient(timeout=30.0) as client:
response = await client.post(webhook, json=data)
result = response.json()
if result.get("errcode") == 0:
self.log(f"企微消息发送成功")
return True
else:
self.log(f"企微消息发送失败: {result.get('errmsg')}", level="ERROR")
return False
except Exception as e:
self.log(f"企微消息发送异常: {str(e)}", level="ERROR")
return False
# ============ 数据库查询 ============
def db_query(self, sql: str, params: Dict[str, Any] = None) -> List[Dict]:
"""
执行 SQL 查询(只读)
Args:
sql: SQL 语句(仅支持 SELECT
params: 查询参数
Returns:
查询结果列表
"""
# 安全检查:只允许 SELECT
sql_upper = sql.strip().upper()
if not sql_upper.startswith("SELECT"):
raise ValueError("只允许 SELECT 查询")
forbidden = ["INSERT", "UPDATE", "DELETE", "DROP", "CREATE", "ALTER", "TRUNCATE"]
for word in forbidden:
if word in sql_upper:
raise ValueError(f"禁止使用 {word} 语句")
db = self._get_db()
try:
result = db.execute(text(sql), params or {})
rows = [dict(row) for row in result.mappings().all()]
self.log(f"SQL 查询成功,返回 {len(rows)} 条记录")
return rows
except Exception as e:
self.log(f"SQL 查询失败: {str(e)}", level="ERROR")
raise
# ============ HTTP 请求 ============
async def http_get(
self,
url: str,
headers: Dict[str, str] = None,
params: Dict[str, Any] = None,
timeout: int = 30
) -> Dict:
"""
发送 HTTP GET 请求
Args:
url: 请求 URL
headers: 请求头
params: 查询参数
timeout: 超时时间(秒)
Returns:
响应数据JSON 解析后)
"""
try:
async with httpx.AsyncClient(timeout=float(timeout)) as client:
response = await client.get(url, headers=headers, params=params)
self.log(f"HTTP GET {url} -> {response.status_code}")
try:
return response.json()
except:
return {"text": response.text, "status_code": response.status_code}
except Exception as e:
self.log(f"HTTP GET 失败: {str(e)}", level="ERROR")
raise
async def http_post(
self,
url: str,
data: Dict[str, Any] = None,
json_data: Dict[str, Any] = None,
headers: Dict[str, str] = None,
timeout: int = 30
) -> Dict:
"""
发送 HTTP POST 请求
Args:
url: 请求 URL
data: 表单数据
json_data: JSON 数据
headers: 请求头
timeout: 超时时间(秒)
Returns:
响应数据JSON 解析后)
"""
try:
async with httpx.AsyncClient(timeout=float(timeout)) as client:
response = await client.post(
url,
data=data,
json=json_data,
headers=headers
)
self.log(f"HTTP POST {url} -> {response.status_code}")
try:
return response.json()
except:
return {"text": response.text, "status_code": response.status_code}
except Exception as e:
self.log(f"HTTP POST 失败: {str(e)}", level="ERROR")
raise
# ============ 变量存储 ============
def get_var(self, key: str, default: Any = None) -> Any:
"""
获取存储的变量
Args:
key: 变量名
default: 默认值
Returns:
变量值JSON 解析后)
"""
db = self._get_db()
try:
result = db.execute(
text("SELECT var_value FROM platform_script_vars WHERE tenant_id = :tid AND var_key = :key"),
{"tid": self.tenant_id, "key": key}
)
row = result.first()
if row:
try:
return json.loads(row[0])
except:
return row[0]
return default
except Exception as e:
self.log(f"获取变量失败: {str(e)}", level="ERROR")
return default
def set_var(self, key: str, value: Any) -> bool:
"""
存储变量(跨执行持久化)
Args:
key: 变量名
value: 变量值(会 JSON 序列化)
Returns:
是否成功
"""
db = self._get_db()
try:
value_str = json.dumps(value, ensure_ascii=False) if not isinstance(value, str) else value
# 使用 REPLACE INTO 实现 upsert
db.execute(
text("""
REPLACE INTO platform_script_vars (tenant_id, var_key, var_value, updated_at)
VALUES (:tid, :key, :value, NOW())
"""),
{"tid": self.tenant_id, "key": key, "value": value_str}
)
db.commit()
self.log(f"变量已存储: {key}")
return True
except Exception as e:
self.log(f"存储变量失败: {str(e)}", level="ERROR")
return False
def delete_var(self, key: str) -> bool:
"""
删除变量
Args:
key: 变量名
Returns:
是否成功
"""
db = self._get_db()
try:
db.execute(
text("DELETE FROM platform_script_vars WHERE tenant_id = :tid AND var_key = :key"),
{"tid": self.tenant_id, "key": key}
)
db.commit()
self.log(f"变量已删除: {key}")
return True
except Exception as e:
self.log(f"删除变量失败: {str(e)}", level="ERROR")
return False
# ============ 日志 ============
def log(self, message: str, level: str = "INFO"):
"""
记录日志
Args:
message: 日志内容
level: 日志级别INFO, WARN, ERROR
"""
timestamp = datetime.now().strftime("%H:%M:%S")
log_entry = f"[{timestamp}] [{level}] {message}"
self._logs.append(log_entry)
# 同时输出到标准日志
if level == "ERROR":
logger.error(f"[Script {self.task_id}] {message}")
else:
logger.info(f"[Script {self.task_id}] {message}")
# 写入 platform_logs
try:
db = self._get_db()
db.execute(
text("""
INSERT INTO platform_logs
(trace_id, app_code, module, level, message, created_at)
VALUES (:trace_id, :app_code, :module, :level, :message, NOW())
"""),
{
"trace_id": self.trace_id,
"app_code": "000-platform",
"module": "script",
"level": level,
"message": message[:2000] # 限制长度
}
)
db.commit()
except Exception as e:
logger.warning(f"Failed to write log to database: {e}")
def get_logs(self) -> List[str]:
"""获取所有日志"""
return self._logs.copy()
def cleanup(self):
"""清理资源"""
self._close_db()