- 支持 Python 脚本定时执行(类似青龙面板) - 内置 SDK:AI大模型、钉钉/企微通知、数据库查询、HTTP请求、变量存储 - 安全沙箱执行,禁用危险模块 - 前端脚本编辑器,支持测试执行 - SDK 文档查看 - 日志通过 TraceID 与 platform_logs 关联
This commit is contained in:
@@ -28,8 +28,10 @@ class TaskCreate(BaseModel):
|
||||
schedule_type: str = "simple" # simple | cron
|
||||
time_points: Optional[List[str]] = None # ["09:00", "14:00"]
|
||||
cron_expression: Optional[str] = None # "0 9,14 * * *"
|
||||
webhook_url: str
|
||||
execution_type: str = "webhook" # webhook | script
|
||||
webhook_url: Optional[str] = None
|
||||
input_params: Optional[dict] = None
|
||||
script_content: Optional[str] = None
|
||||
is_enabled: bool = True
|
||||
|
||||
|
||||
@@ -39,8 +41,15 @@ class TaskUpdate(BaseModel):
|
||||
schedule_type: Optional[str] = None
|
||||
time_points: Optional[List[str]] = None
|
||||
cron_expression: Optional[str] = None
|
||||
execution_type: Optional[str] = None
|
||||
webhook_url: Optional[str] = None
|
||||
input_params: Optional[dict] = None
|
||||
script_content: Optional[str] = None
|
||||
|
||||
|
||||
class ScriptTestRequest(BaseModel):
|
||||
tenant_id: str
|
||||
script_content: str
|
||||
|
||||
|
||||
# API Endpoints
|
||||
@@ -110,6 +119,14 @@ async def create_task(
|
||||
if not data.cron_expression:
|
||||
raise HTTPException(status_code=400, detail="CRON模式需要提供表达式")
|
||||
|
||||
# 验证执行配置
|
||||
if data.execution_type == "webhook":
|
||||
if not data.webhook_url:
|
||||
raise HTTPException(status_code=400, detail="Webhook模式需要提供URL")
|
||||
elif data.execution_type == "script":
|
||||
if not data.script_content:
|
||||
raise HTTPException(status_code=400, detail="脚本模式需要提供脚本内容")
|
||||
|
||||
# 插入数据库
|
||||
import json
|
||||
time_points_json = json.dumps(data.time_points) if data.time_points else None
|
||||
@@ -119,9 +136,9 @@ async def create_task(
|
||||
text("""
|
||||
INSERT INTO platform_scheduled_tasks
|
||||
(tenant_id, task_name, task_desc, schedule_type, time_points,
|
||||
cron_expression, webhook_url, input_params, is_enabled)
|
||||
cron_expression, execution_type, webhook_url, input_params, script_content, is_enabled)
|
||||
VALUES (:tenant_id, :task_name, :task_desc, :schedule_type, :time_points,
|
||||
:cron_expression, :webhook_url, :input_params, :is_enabled)
|
||||
:cron_expression, :execution_type, :webhook_url, :input_params, :script_content, :is_enabled)
|
||||
"""),
|
||||
{
|
||||
"tenant_id": data.tenant_id,
|
||||
@@ -130,8 +147,10 @@ async def create_task(
|
||||
"schedule_type": data.schedule_type,
|
||||
"time_points": time_points_json,
|
||||
"cron_expression": data.cron_expression,
|
||||
"execution_type": data.execution_type,
|
||||
"webhook_url": data.webhook_url,
|
||||
"input_params": input_params_json,
|
||||
"script_content": data.script_content,
|
||||
"is_enabled": 1 if data.is_enabled else 0
|
||||
}
|
||||
)
|
||||
@@ -209,9 +228,15 @@ async def update_task(
|
||||
if data.cron_expression is not None:
|
||||
updates.append("cron_expression = :cron_expression")
|
||||
params["cron_expression"] = data.cron_expression
|
||||
if data.execution_type is not None:
|
||||
updates.append("execution_type = :execution_type")
|
||||
params["execution_type"] = data.execution_type
|
||||
if data.webhook_url is not None:
|
||||
updates.append("webhook_url = :webhook_url")
|
||||
params["webhook_url"] = data.webhook_url
|
||||
if data.script_content is not None:
|
||||
updates.append("script_content = :script_content")
|
||||
params["script_content"] = data.script_content
|
||||
if data.input_params is not None:
|
||||
updates.append("input_params = :input_params")
|
||||
params["input_params"] = json.dumps(data.input_params)
|
||||
@@ -354,3 +379,96 @@ async def get_task_logs(
|
||||
"size": size,
|
||||
"items": logs
|
||||
}
|
||||
|
||||
|
||||
@router.post("/test-script")
|
||||
async def test_script(
|
||||
data: ScriptTestRequest,
|
||||
user: User = Depends(require_operator)
|
||||
):
|
||||
"""测试执行脚本(不记录日志)"""
|
||||
from ..services.script_executor import test_script as run_test
|
||||
|
||||
if not data.script_content or not data.script_content.strip():
|
||||
raise HTTPException(status_code=400, detail="脚本内容不能为空")
|
||||
|
||||
result = await run_test(
|
||||
tenant_id=data.tenant_id,
|
||||
script_content=data.script_content
|
||||
)
|
||||
|
||||
return result.to_dict()
|
||||
|
||||
|
||||
@router.get("/sdk-docs")
|
||||
async def get_sdk_docs():
|
||||
"""获取 SDK 文档"""
|
||||
return {
|
||||
"description": "脚本执行 SDK 文档",
|
||||
"methods": [
|
||||
{
|
||||
"name": "ai(prompt, system=None, model='gemini-2.5-flash')",
|
||||
"description": "调用大模型生成内容",
|
||||
"example": "result = ai('帮我写一段营销文案')"
|
||||
},
|
||||
{
|
||||
"name": "dingtalk(webhook, content, msg_type='text', at_mobiles=None)",
|
||||
"description": "发送钉钉群消息",
|
||||
"example": "dingtalk('https://oapi.dingtalk.com/robot/send?access_token=xxx', '消息内容')"
|
||||
},
|
||||
{
|
||||
"name": "wecom(webhook, content, msg_type='text')",
|
||||
"description": "发送企业微信群消息",
|
||||
"example": "wecom('https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=xxx', '消息内容')"
|
||||
},
|
||||
{
|
||||
"name": "db(sql, params=None)",
|
||||
"description": "执行 SQL 查询(仅支持 SELECT)",
|
||||
"example": "rows = db('SELECT * FROM users WHERE id = :id', {'id': 1})"
|
||||
},
|
||||
{
|
||||
"name": "http_get(url, headers=None, params=None)",
|
||||
"description": "发送 HTTP GET 请求",
|
||||
"example": "data = http_get('https://api.example.com/data')"
|
||||
},
|
||||
{
|
||||
"name": "http_post(url, data=None, json_data=None, headers=None)",
|
||||
"description": "发送 HTTP POST 请求",
|
||||
"example": "data = http_post('https://api.example.com/submit', json_data={'key': 'value'})"
|
||||
},
|
||||
{
|
||||
"name": "get_var(key, default=None)",
|
||||
"description": "获取存储的变量(跨执行持久化)",
|
||||
"example": "count = get_var('run_count', 0)"
|
||||
},
|
||||
{
|
||||
"name": "set_var(key, value)",
|
||||
"description": "存储变量(跨执行持久化)",
|
||||
"example": "set_var('run_count', count + 1)"
|
||||
},
|
||||
{
|
||||
"name": "log(message, level='INFO')",
|
||||
"description": "记录日志",
|
||||
"example": "log('任务执行完成')"
|
||||
}
|
||||
],
|
||||
"example_script": '''# 示例:每日推送 AI 生成的内容到钉钉
|
||||
import json
|
||||
|
||||
# 获取历史数据
|
||||
history = get_var('history', [])
|
||||
|
||||
# 调用 AI 生成内容
|
||||
prompt = f"根据以下信息生成今日营销文案:{json.dumps(history[-5:], ensure_ascii=False)}"
|
||||
content = ai(prompt, system="你是一个专业的营销文案专家")
|
||||
|
||||
# 发送到钉钉
|
||||
dingtalk(
|
||||
webhook="你的钉钉机器人Webhook",
|
||||
content=content
|
||||
)
|
||||
|
||||
# 记录日志
|
||||
log(f"已发送: {content[:50]}...")
|
||||
'''
|
||||
}
|
||||
|
||||
@@ -69,26 +69,64 @@ async def execute_task(task_id: int):
|
||||
result = db.execute(text("SELECT LAST_INSERT_ID() as id"))
|
||||
log_id = result.scalar()
|
||||
|
||||
# 4. 调用 webhook
|
||||
webhook_url = task["webhook_url"]
|
||||
input_params = task["input_params"] or {}
|
||||
# 生成 trace_id
|
||||
trace_id = f"task_{task_id}_{datetime.now().strftime('%Y%m%d%H%M%S%f')}"
|
||||
|
||||
async with httpx.AsyncClient(timeout=300.0) as client:
|
||||
response = await client.post(
|
||||
webhook_url,
|
||||
json=input_params,
|
||||
headers={"Content-Type": "application/json"}
|
||||
)
|
||||
# 4. 根据执行类型分发
|
||||
execution_type = task.get("execution_type", "webhook")
|
||||
|
||||
if execution_type == "script":
|
||||
# 脚本执行模式
|
||||
from .script_executor import execute_script as run_script
|
||||
|
||||
response_code = response.status_code
|
||||
response_body = response.text[:5000] if response.text else "" # 限制存储长度
|
||||
|
||||
if response.is_success:
|
||||
status = "success"
|
||||
error_message = None
|
||||
else:
|
||||
script_content = task.get("script_content", "")
|
||||
if not script_content:
|
||||
status = "failed"
|
||||
error_message = f"HTTP {response_code}"
|
||||
error_message = "脚本内容为空"
|
||||
response_code = None
|
||||
response_body = ""
|
||||
else:
|
||||
script_result = await run_script(
|
||||
task_id=task_id,
|
||||
tenant_id=task["tenant_id"],
|
||||
script_content=script_content,
|
||||
trace_id=trace_id
|
||||
)
|
||||
|
||||
if script_result.success:
|
||||
status = "success"
|
||||
error_message = None
|
||||
else:
|
||||
status = "failed"
|
||||
error_message = script_result.error
|
||||
|
||||
response_code = None
|
||||
response_body = script_result.output[:5000] if script_result.output else ""
|
||||
|
||||
# 添加日志到响应体
|
||||
if script_result.logs:
|
||||
response_body += "\n\n--- 执行日志 ---\n" + "\n".join(script_result.logs[-20:])
|
||||
else:
|
||||
# Webhook 执行模式
|
||||
webhook_url = task["webhook_url"]
|
||||
input_params = task["input_params"] or {}
|
||||
|
||||
async with httpx.AsyncClient(timeout=300.0) as client:
|
||||
response = await client.post(
|
||||
webhook_url,
|
||||
json=input_params,
|
||||
headers={"Content-Type": "application/json"}
|
||||
)
|
||||
|
||||
response_code = response.status_code
|
||||
response_body = response.text[:5000] if response.text else "" # 限制存储长度
|
||||
|
||||
if response.is_success:
|
||||
status = "success"
|
||||
error_message = None
|
||||
else:
|
||||
status = "failed"
|
||||
error_message = f"HTTP {response_code}"
|
||||
|
||||
# 5. 更新执行日志
|
||||
db.execute(
|
||||
|
||||
262
backend/app/services/script_executor.py
Normal file
262
backend/app/services/script_executor.py
Normal file
@@ -0,0 +1,262 @@
|
||||
"""脚本执行器 - 安全执行 Python 脚本"""
|
||||
import asyncio
|
||||
import io
|
||||
import logging
|
||||
import sys
|
||||
import traceback
|
||||
from contextlib import redirect_stdout, redirect_stderr
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict
|
||||
|
||||
from .script_sdk import ScriptSDK
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 执行超时时间(秒)
|
||||
SCRIPT_TIMEOUT = 300 # 5 分钟
|
||||
|
||||
# 禁止导入的模块
|
||||
FORBIDDEN_MODULES = {
|
||||
'os', 'subprocess', 'sys', 'builtins', '__builtins__',
|
||||
'importlib', 'eval', 'exec', 'compile',
|
||||
'open', 'file', 'input',
|
||||
'socket', 'multiprocessing', 'threading',
|
||||
'pickle', 'marshal', 'ctypes',
|
||||
'code', 'codeop', 'pty', 'tty',
|
||||
}
|
||||
|
||||
|
||||
class ScriptExecutionResult:
|
||||
"""脚本执行结果"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
success: bool,
|
||||
output: str = "",
|
||||
error: str = None,
|
||||
logs: list = None,
|
||||
execution_time_ms: int = 0
|
||||
):
|
||||
self.success = success
|
||||
self.output = output
|
||||
self.error = error
|
||||
self.logs = logs or []
|
||||
self.execution_time_ms = execution_time_ms
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return {
|
||||
"success": self.success,
|
||||
"output": self.output,
|
||||
"error": self.error,
|
||||
"logs": self.logs,
|
||||
"execution_time_ms": self.execution_time_ms
|
||||
}
|
||||
|
||||
|
||||
def create_safe_builtins() -> Dict[str, Any]:
|
||||
"""创建安全的内置函数集"""
|
||||
import builtins
|
||||
|
||||
# 允许的内置函数
|
||||
allowed = [
|
||||
'abs', 'all', 'any', 'ascii', 'bin', 'bool', 'bytearray', 'bytes',
|
||||
'callable', 'chr', 'complex', 'dict', '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', 'slice', 'sorted', 'str', 'sum', 'tuple', 'type', 'zip',
|
||||
'True', 'False', 'None',
|
||||
]
|
||||
|
||||
safe_builtins = {}
|
||||
for name in allowed:
|
||||
if hasattr(builtins, name):
|
||||
safe_builtins[name] = getattr(builtins, name)
|
||||
|
||||
# 添加安全的 import 函数
|
||||
def safe_import(name, *args, **kwargs):
|
||||
"""安全的 import 函数,只允许特定模块"""
|
||||
allowed_modules = {
|
||||
'json', 'datetime', 'time', 're', 'math', 'random',
|
||||
'collections', 'itertools', 'functools', 'operator',
|
||||
'string', 'textwrap', 'unicodedata',
|
||||
'hashlib', 'base64', 'urllib.parse',
|
||||
}
|
||||
|
||||
if name in FORBIDDEN_MODULES:
|
||||
raise ImportError(f"禁止导入模块: {name}")
|
||||
|
||||
if name not in allowed_modules and not name.startswith('urllib.parse'):
|
||||
raise ImportError(f"不允许导入模块: {name},允许的模块: {', '.join(sorted(allowed_modules))}")
|
||||
|
||||
return __builtins__['__import__'](name, *args, **kwargs)
|
||||
|
||||
safe_builtins['__import__'] = safe_import
|
||||
|
||||
return safe_builtins
|
||||
|
||||
|
||||
async def execute_script(
|
||||
task_id: int,
|
||||
tenant_id: str,
|
||||
script_content: str,
|
||||
trace_id: str = None
|
||||
) -> ScriptExecutionResult:
|
||||
"""
|
||||
执行 Python 脚本
|
||||
|
||||
Args:
|
||||
task_id: 任务 ID
|
||||
tenant_id: 租户 ID
|
||||
script_content: 脚本内容
|
||||
trace_id: 追踪 ID
|
||||
|
||||
Returns:
|
||||
ScriptExecutionResult: 执行结果
|
||||
"""
|
||||
start_time = datetime.now()
|
||||
sdk = None
|
||||
|
||||
try:
|
||||
# 创建 SDK 实例
|
||||
sdk = ScriptSDK(tenant_id, task_id, trace_id)
|
||||
|
||||
# 准备执行环境
|
||||
script_globals = {
|
||||
'__builtins__': create_safe_builtins(),
|
||||
'__name__': '__script__',
|
||||
|
||||
# SDK 实例
|
||||
'sdk': sdk,
|
||||
|
||||
# 快捷方法(同步包装)
|
||||
'ai': lambda *args, **kwargs: asyncio.get_event_loop().run_until_complete(sdk.ai_chat(*args, **kwargs)),
|
||||
'dingtalk': lambda *args, **kwargs: asyncio.get_event_loop().run_until_complete(sdk.send_dingtalk(*args, **kwargs)),
|
||||
'wecom': lambda *args, **kwargs: asyncio.get_event_loop().run_until_complete(sdk.send_wecom(*args, **kwargs)),
|
||||
'http_get': lambda *args, **kwargs: asyncio.get_event_loop().run_until_complete(sdk.http_get(*args, **kwargs)),
|
||||
'http_post': lambda *args, **kwargs: asyncio.get_event_loop().run_until_complete(sdk.http_post(*args, **kwargs)),
|
||||
|
||||
# 同步方法
|
||||
'db': sdk.db_query,
|
||||
'get_var': sdk.get_var,
|
||||
'set_var': sdk.set_var,
|
||||
'delete_var': sdk.delete_var,
|
||||
'log': sdk.log,
|
||||
|
||||
# 常用模块
|
||||
'json': __import__('json'),
|
||||
'datetime': __import__('datetime'),
|
||||
're': __import__('re'),
|
||||
'math': __import__('math'),
|
||||
'random': __import__('random'),
|
||||
}
|
||||
|
||||
# 捕获输出
|
||||
stdout = io.StringIO()
|
||||
stderr = io.StringIO()
|
||||
|
||||
sdk.log("脚本开始执行")
|
||||
|
||||
# 编译并执行脚本
|
||||
try:
|
||||
# 编译脚本
|
||||
code = compile(script_content, '<script>', 'exec')
|
||||
|
||||
# 执行(带超时)
|
||||
async def run_script():
|
||||
with redirect_stdout(stdout), redirect_stderr(stderr):
|
||||
exec(code, script_globals)
|
||||
|
||||
await asyncio.wait_for(run_script(), timeout=SCRIPT_TIMEOUT)
|
||||
|
||||
execution_time = int((datetime.now() - start_time).total_seconds() * 1000)
|
||||
sdk.log(f"脚本执行完成,耗时 {execution_time}ms")
|
||||
|
||||
return ScriptExecutionResult(
|
||||
success=True,
|
||||
output=stdout.getvalue(),
|
||||
logs=sdk.get_logs(),
|
||||
execution_time_ms=execution_time
|
||||
)
|
||||
|
||||
except asyncio.TimeoutError:
|
||||
execution_time = int((datetime.now() - start_time).total_seconds() * 1000)
|
||||
error_msg = f"脚本执行超时(超过 {SCRIPT_TIMEOUT} 秒)"
|
||||
sdk.log(error_msg, level="ERROR")
|
||||
|
||||
return ScriptExecutionResult(
|
||||
success=False,
|
||||
output=stdout.getvalue(),
|
||||
error=error_msg,
|
||||
logs=sdk.get_logs(),
|
||||
execution_time_ms=execution_time
|
||||
)
|
||||
|
||||
except SyntaxError as e:
|
||||
execution_time = int((datetime.now() - start_time).total_seconds() * 1000)
|
||||
error_msg = f"语法错误: 第 {e.lineno} 行 - {e.msg}"
|
||||
sdk.log(error_msg, level="ERROR")
|
||||
|
||||
return ScriptExecutionResult(
|
||||
success=False,
|
||||
output=stdout.getvalue(),
|
||||
error=error_msg,
|
||||
logs=sdk.get_logs(),
|
||||
execution_time_ms=execution_time
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
execution_time = int((datetime.now() - start_time).total_seconds() * 1000)
|
||||
error_msg = f"{type(e).__name__}: {str(e)}"
|
||||
|
||||
# 获取详细的错误堆栈
|
||||
tb = traceback.format_exc()
|
||||
sdk.log(f"执行错误: {error_msg}\n{tb}", level="ERROR")
|
||||
|
||||
return ScriptExecutionResult(
|
||||
success=False,
|
||||
output=stdout.getvalue(),
|
||||
error=error_msg,
|
||||
logs=sdk.get_logs(),
|
||||
execution_time_ms=execution_time
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
execution_time = int((datetime.now() - start_time).total_seconds() * 1000)
|
||||
error_msg = f"执行器错误: {str(e)}"
|
||||
logger.error(f"Script executor error: {e}", exc_info=True)
|
||||
|
||||
return ScriptExecutionResult(
|
||||
success=False,
|
||||
error=error_msg,
|
||||
logs=sdk.get_logs() if sdk else [],
|
||||
execution_time_ms=execution_time
|
||||
)
|
||||
|
||||
finally:
|
||||
# 清理资源
|
||||
if sdk:
|
||||
sdk.cleanup()
|
||||
|
||||
|
||||
async def test_script(
|
||||
tenant_id: str,
|
||||
script_content: str
|
||||
) -> ScriptExecutionResult:
|
||||
"""
|
||||
测试执行脚本(不记录日志到数据库)
|
||||
|
||||
Args:
|
||||
tenant_id: 租户 ID
|
||||
script_content: 脚本内容
|
||||
|
||||
Returns:
|
||||
ScriptExecutionResult: 执行结果
|
||||
"""
|
||||
return await execute_script(
|
||||
task_id=0, # 测试用 ID
|
||||
tenant_id=tenant_id,
|
||||
script_content=script_content,
|
||||
trace_id=f"test_{datetime.now().strftime('%Y%m%d%H%M%S')}"
|
||||
)
|
||||
443
backend/app/services/script_sdk.py
Normal file
443
backend/app/services/script_sdk.py
Normal file
@@ -0,0 +1,443 @@
|
||||
"""脚本执行 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()
|
||||
Reference in New Issue
Block a user