feat: 实现定时任务系统
All checks were successful
continuous-integration/drone/push Build is passing

- 新增 platform_scheduled_tasks, platform_task_logs, platform_script_vars, platform_secrets 数据库表
- 实现 ScriptSDK 提供 AI/通知/DB/HTTP/变量存储/参数获取等功能
- 实现安全的脚本执行器,支持沙箱环境和禁止危险操作
- 实现 APScheduler 调度服务,支持简单时间点和 CRON 表达式
- 新增定时任务 API 路由,包含 CRUD、执行、日志、密钥管理
- 新增定时任务前端页面,支持脚本编辑、测试运行、日志查看
This commit is contained in:
2026-01-28 16:38:19 +08:00
parent 7806072b17
commit 104487f082
19 changed files with 1870 additions and 5406 deletions

View File

@@ -14,12 +14,10 @@ from .routers.wechat import router as wechat_router
from .routers.alerts import router as alerts_router
from .routers.cost import router as cost_router
from .routers.quota import router as quota_router
from .routers.tool_configs import router as tool_configs_router
from .routers.tasks import router as tasks_router
from .routers.scripts import router as scripts_router
from .middleware import TraceMiddleware, setup_exception_handlers, RequestLoggerMiddleware
from .middleware.trace import setup_logging
from .services.scheduler import start_scheduler, shutdown_scheduler
from .services.scheduler import scheduler_service
# 配置日志(包含 TraceID
setup_logging(level=logging.INFO, include_trace=True)
@@ -70,29 +68,20 @@ app.include_router(wechat_router, prefix="/api")
app.include_router(alerts_router, prefix="/api")
app.include_router(cost_router, prefix="/api")
app.include_router(quota_router, prefix="/api")
app.include_router(tool_configs_router, prefix="/api")
app.include_router(tasks_router, prefix="/api")
app.include_router(scripts_router, prefix="/api")
app.include_router(tasks_router)
# 应用生命周期事件
@app.on_event("startup")
async def startup_event():
"""应用启动时初始化调度器"""
try:
start_scheduler()
logging.info("Scheduler started successfully")
except Exception as e:
logging.error(f"Failed to start scheduler: {e}")
"""应用启动时启动调度器"""
scheduler_service.start()
@app.on_event("shutdown")
async def shutdown_event():
"""应用关闭时停止调度器"""
try:
shutdown_scheduler()
logging.info("Scheduler shutdown successfully")
except Exception as e:
logging.error(f"Failed to shutdown scheduler: {e}")
"""应用关闭时关闭调度器"""
scheduler_service.shutdown()
@app.get("/")

View File

@@ -8,6 +8,7 @@ from .stats import AICallEvent, TenantUsageDaily
from .logs import PlatformLog
from .alert import AlertRule, AlertRecord, NotificationChannel
from .pricing import ModelPricing, TenantBilling
from .scheduled_task import ScheduledTask, TaskLog, ScriptVar, Secret
__all__ = [
"Tenant",
@@ -24,5 +25,9 @@ __all__ = [
"AlertRecord",
"NotificationChannel",
"ModelPricing",
"TenantBilling"
"TenantBilling",
"ScheduledTask",
"TaskLog",
"ScriptVar",
"Secret"
]

View File

@@ -18,11 +18,6 @@ class App(Base):
# [{"code": "brainstorm", "name": "头脑风暴", "path": "/brainstorm"}, ...]
tools = Column(Text)
# 配置项定义JSON 数组)- 定义租户可配置的参数
# [{"key": "industry", "label": "行业类型", "type": "radio", "options": [...], "default": "...", "required": false}, ...]
# type: text(文本) | radio(单选) | select(下拉多选) | switch(开关)
config_schema = Column(Text)
# 是否需要企微JS-SDK
require_jssdk = Column(SmallInteger, default=0) # 0-不需要 1-需要

View File

@@ -0,0 +1,96 @@
"""定时任务相关模型"""
from datetime import datetime
from sqlalchemy import Column, BigInteger, Integer, String, Text, Enum, SmallInteger, TIMESTAMP, DateTime
from ..database import Base
class ScheduledTask(Base):
"""定时任务表"""
__tablename__ = "platform_scheduled_tasks"
id = Column(Integer, primary_key=True, autoincrement=True)
tenant_id = Column(String(50))
task_name = Column(String(100), nullable=False)
task_type = Column(Enum('webhook', 'script'), nullable=False, default='script')
# 调度配置
schedule_type = Column(Enum('simple', 'cron'), nullable=False, default='simple')
time_points = Column(Text) # JSON数组 ["08:00", "12:00"]
cron_expression = Column(String(100))
# Webhook配置
webhook_url = Column(String(500))
webhook_method = Column(String(10), default='POST')
webhook_headers = Column(Text) # JSON格式
# 脚本配置
script_content = Column(Text)
script_timeout = Column(Integer, default=300)
# 输入参数
input_params = Column(Text) # JSON格式
# 重试配置
retry_count = Column(Integer, default=0)
retry_interval = Column(Integer, default=60)
# 告警配置
alert_on_failure = Column(SmallInteger, default=0)
alert_webhook = Column(String(500))
# 状态
status = Column(SmallInteger, default=1) # 0-禁用 1-启用
last_run_at = Column(DateTime)
last_run_status = Column(String(20))
created_at = Column(TIMESTAMP, default=datetime.now)
updated_at = Column(TIMESTAMP, default=datetime.now, onupdate=datetime.now)
class TaskLog(Base):
"""任务执行日志"""
__tablename__ = "platform_task_logs"
id = Column(BigInteger, primary_key=True, autoincrement=True)
task_id = Column(Integer, nullable=False)
tenant_id = Column(String(50))
trace_id = Column(String(100))
status = Column(Enum('running', 'success', 'failed'), nullable=False)
started_at = Column(DateTime, nullable=False)
finished_at = Column(DateTime)
duration_ms = Column(Integer)
output = Column(Text)
error = Column(Text)
retry_count = Column(Integer, default=0)
created_at = Column(TIMESTAMP, default=datetime.now)
class ScriptVar(Base):
"""脚本变量存储"""
__tablename__ = "platform_script_vars"
id = Column(Integer, primary_key=True, autoincrement=True)
task_id = Column(Integer, nullable=False)
tenant_id = Column(String(50))
var_key = Column(String(100), nullable=False)
var_value = Column(Text) # JSON格式
created_at = Column(TIMESTAMP, default=datetime.now)
updated_at = Column(TIMESTAMP, default=datetime.now, onupdate=datetime.now)
class Secret(Base):
"""密钥管理"""
__tablename__ = "platform_secrets"
id = Column(Integer, primary_key=True, autoincrement=True)
tenant_id = Column(String(50)) # NULL为全局
secret_key = Column(String(100), nullable=False)
secret_value = Column(Text, nullable=False)
description = Column(String(255))
created_at = Column(TIMESTAMP, default=datetime.now)
updated_at = Column(TIMESTAMP, default=datetime.now, onupdate=datetime.now)

View File

@@ -23,10 +23,6 @@ class TenantApp(Base):
# 功能权限
allowed_tools = Column(Text) # JSON 数组
# 自定义配置JSON 数组)
# [{"key": "industry", "value": "medical_beauty", "remark": "医美行业"}, ...]
custom_configs = Column(Text)
status = Column(SmallInteger, default=1)
created_at = Column(TIMESTAMP, default=datetime.now)
updated_at = Column(TIMESTAMP, default=datetime.now, onupdate=datetime.now)

View File

@@ -23,18 +23,6 @@ class ToolItem(BaseModel):
path: str
class ConfigSchemaItem(BaseModel):
"""配置项定义"""
key: str # 配置键
label: str # 显示标签
type: str # text | radio | select | switch
options: Optional[List[str]] = None # radio/select 的选项值
option_labels: Optional[dict] = None # 选项显示名称 {"value": "显示名"}
default: Optional[str] = None # 默认值
placeholder: Optional[str] = None # 输入提示text类型
required: bool = False # 是否必填
class AppCreate(BaseModel):
"""创建应用"""
app_code: str
@@ -42,7 +30,6 @@ class AppCreate(BaseModel):
base_url: Optional[str] = None
description: Optional[str] = None
tools: Optional[List[ToolItem]] = None
config_schema: Optional[List[ConfigSchemaItem]] = None
require_jssdk: bool = False
@@ -52,7 +39,6 @@ class AppUpdate(BaseModel):
base_url: Optional[str] = None
description: Optional[str] = None
tools: Optional[List[ToolItem]] = None
config_schema: Optional[List[ConfigSchemaItem]] = None
require_jssdk: Optional[bool] = None
status: Optional[int] = None
@@ -133,7 +119,6 @@ async def create_app(
base_url=data.base_url,
description=data.description,
tools=json.dumps([t.model_dump() for t in data.tools], ensure_ascii=False) if data.tools else None,
config_schema=json.dumps([c.model_dump() for c in data.config_schema], ensure_ascii=False) if data.config_schema else None,
require_jssdk=1 if data.require_jssdk else 0,
status=1
)
@@ -165,13 +150,6 @@ async def update_app(
else:
update_data['tools'] = None
# 处理 config_schema JSON
if 'config_schema' in update_data:
if update_data['config_schema']:
update_data['config_schema'] = json.dumps([c.model_dump() if hasattr(c, 'model_dump') else c for c in update_data['config_schema']], ensure_ascii=False)
else:
update_data['config_schema'] = None
# 处理 require_jssdk
if 'require_jssdk' in update_data:
update_data['require_jssdk'] = 1 if update_data['require_jssdk'] else 0
@@ -281,21 +259,6 @@ async def get_app_tools(
return tools
@router.get("/{app_code}/config-schema")
async def get_app_config_schema(
app_code: str,
user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""获取应用的配置项定义(用于租户订阅时渲染表单)"""
app = db.query(App).filter(App.app_code == app_code).first()
if not app:
raise HTTPException(status_code=404, detail="应用不存在")
config_schema = json.loads(app.config_schema) if app.config_schema else []
return config_schema
def format_app(app: App) -> dict:
"""格式化应用数据"""
return {
@@ -305,7 +268,6 @@ def format_app(app: App) -> dict:
"base_url": app.base_url,
"description": app.description,
"tools": json.loads(app.tools) if app.tools else [],
"config_schema": json.loads(app.config_schema) if app.config_schema else [],
"require_jssdk": bool(app.require_jssdk),
"status": app.status,
"created_at": app.created_at,

View File

@@ -1,325 +0,0 @@
"""脚本管理路由"""
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel
from typing import Optional, List
from sqlalchemy.orm import Session
from sqlalchemy import text
from datetime import datetime
from ..database import get_db
from .auth import get_current_user, require_operator
from ..models.user import User
router = APIRouter(prefix="/scripts", tags=["脚本管理"])
# Schemas
class ScriptCreate(BaseModel):
tenant_id: Optional[str] = None
name: str
filename: Optional[str] = None
description: Optional[str] = None
script_content: str
category: Optional[str] = None
is_enabled: bool = True
class ScriptUpdate(BaseModel):
name: Optional[str] = None
filename: Optional[str] = None
description: Optional[str] = None
script_content: Optional[str] = None
category: Optional[str] = None
is_enabled: Optional[bool] = None
class ScriptRunRequest(BaseModel):
tenant_id: Optional[str] = None # 可指定以哪个租户身份运行
# API Endpoints
@router.get("")
async def list_scripts(
page: int = Query(1, ge=1),
size: int = Query(50, ge=1, le=200),
tenant_id: Optional[str] = None,
category: Optional[str] = None,
keyword: Optional[str] = None,
user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""获取脚本列表"""
where_clauses = []
params = {}
if tenant_id:
where_clauses.append("(tenant_id = :tenant_id OR tenant_id IS NULL)")
params["tenant_id"] = tenant_id
if category:
where_clauses.append("category = :category")
params["category"] = category
if keyword:
where_clauses.append("(name LIKE :keyword OR description LIKE :keyword)")
params["keyword"] = f"%{keyword}%"
where_sql = " AND ".join(where_clauses) if where_clauses else "1=1"
# 查询总数
count_result = db.execute(
text(f"SELECT COUNT(*) FROM platform_scripts WHERE {where_sql}"),
params
)
total = count_result.scalar()
# 查询列表
params["offset"] = (page - 1) * size
params["limit"] = size
result = db.execute(
text(f"""
SELECT id, tenant_id, name, filename, description, category,
is_enabled, last_run_at, last_run_status, created_by, created_at, updated_at,
LENGTH(script_content) as content_length
FROM platform_scripts
WHERE {where_sql}
ORDER BY updated_at DESC, id DESC
LIMIT :limit OFFSET :offset
"""),
params
)
scripts = [dict(row) for row in result.mappings().all()]
# 获取分类列表
cat_result = db.execute(
text("SELECT DISTINCT category FROM platform_scripts WHERE category IS NOT NULL AND category != ''")
)
categories = [row[0] for row in cat_result.fetchall()]
return {
"total": total,
"page": page,
"size": size,
"items": scripts,
"categories": categories
}
@router.get("/{script_id}")
async def get_script(
script_id: int,
user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""获取脚本详情(包含内容)"""
result = db.execute(
text("SELECT * FROM platform_scripts WHERE id = :id"),
{"id": script_id}
)
script = result.mappings().first()
if not script:
raise HTTPException(status_code=404, detail="脚本不存在")
return dict(script)
@router.post("")
async def create_script(
data: ScriptCreate,
user: User = Depends(require_operator),
db: Session = Depends(get_db)
):
"""创建脚本"""
# 自动生成文件名
filename = data.filename
if not filename and data.name:
# 转换为安全的文件名
import re
safe_name = re.sub(r'[^\w\u4e00-\u9fff]', '_', data.name)
filename = f"{safe_name}.py"
db.execute(
text("""
INSERT INTO platform_scripts
(tenant_id, name, filename, description, script_content, category, is_enabled, created_by)
VALUES (:tenant_id, :name, :filename, :description, :script_content, :category, :is_enabled, :created_by)
"""),
{
"tenant_id": data.tenant_id,
"name": data.name,
"filename": filename,
"description": data.description,
"script_content": data.script_content,
"category": data.category,
"is_enabled": 1 if data.is_enabled else 0,
"created_by": user.username if hasattr(user, 'username') else None
}
)
db.commit()
result = db.execute(text("SELECT LAST_INSERT_ID() as id"))
script_id = result.scalar()
return {"id": script_id, "message": "创建成功"}
@router.put("/{script_id}")
async def update_script(
script_id: int,
data: ScriptUpdate,
user: User = Depends(require_operator),
db: Session = Depends(get_db)
):
"""更新脚本"""
# 检查是否存在
result = db.execute(
text("SELECT id FROM platform_scripts WHERE id = :id"),
{"id": script_id}
)
if not result.scalar():
raise HTTPException(status_code=404, detail="脚本不存在")
updates = []
params = {"id": script_id}
if data.name is not None:
updates.append("name = :name")
params["name"] = data.name
if data.filename is not None:
updates.append("filename = :filename")
params["filename"] = data.filename
if data.description is not None:
updates.append("description = :description")
params["description"] = data.description
if data.script_content is not None:
updates.append("script_content = :script_content")
params["script_content"] = data.script_content
if data.category is not None:
updates.append("category = :category")
params["category"] = data.category
if data.is_enabled is not None:
updates.append("is_enabled = :is_enabled")
params["is_enabled"] = 1 if data.is_enabled else 0
if updates:
db.execute(
text(f"UPDATE platform_scripts SET {', '.join(updates)} WHERE id = :id"),
params
)
db.commit()
return {"message": "更新成功"}
@router.delete("/{script_id}")
async def delete_script(
script_id: int,
user: User = Depends(require_operator),
db: Session = Depends(get_db)
):
"""删除脚本"""
result = db.execute(
text("SELECT id FROM platform_scripts WHERE id = :id"),
{"id": script_id}
)
if not result.scalar():
raise HTTPException(status_code=404, detail="脚本不存在")
db.execute(
text("DELETE FROM platform_scripts WHERE id = :id"),
{"id": script_id}
)
db.commit()
return {"message": "删除成功"}
@router.post("/{script_id}/run")
async def run_script(
script_id: int,
data: ScriptRunRequest = None,
user: User = Depends(require_operator),
db: Session = Depends(get_db)
):
"""执行脚本"""
from ..services.script_executor import test_script as run_test
# 获取脚本
result = db.execute(
text("SELECT * FROM platform_scripts WHERE id = :id"),
{"id": script_id}
)
script = result.mappings().first()
if not script:
raise HTTPException(status_code=404, detail="脚本不存在")
if not script["script_content"]:
raise HTTPException(status_code=400, detail="脚本内容为空")
# 确定租户ID
tenant_id = (data.tenant_id if data else None) or script["tenant_id"] or "system"
# 执行脚本
exec_result = await run_test(
tenant_id=tenant_id,
script_content=script["script_content"]
)
# 更新执行状态
status = "success" if exec_result.success else "failed"
db.execute(
text("""
UPDATE platform_scripts
SET last_run_at = NOW(), last_run_status = :status
WHERE id = :id
"""),
{"id": script_id, "status": status}
)
db.commit()
return exec_result.to_dict()
@router.post("/{script_id}/copy")
async def copy_script(
script_id: int,
user: User = Depends(require_operator),
db: Session = Depends(get_db)
):
"""复制脚本"""
# 获取原脚本
result = db.execute(
text("SELECT * FROM platform_scripts WHERE id = :id"),
{"id": script_id}
)
script = result.mappings().first()
if not script:
raise HTTPException(status_code=404, detail="脚本不存在")
# 创建副本
new_name = f"{script['name']} - 副本"
db.execute(
text("""
INSERT INTO platform_scripts
(tenant_id, name, filename, description, script_content, category, is_enabled, created_by)
VALUES (:tenant_id, :name, :filename, :description, :script_content, :category, 1, :created_by)
"""),
{
"tenant_id": script["tenant_id"],
"name": new_name,
"filename": None,
"description": script["description"],
"script_content": script["script_content"],
"category": script["category"],
"created_by": user.username if hasattr(user, 'username') else None
}
)
db.commit()
result = db.execute(text("SELECT LAST_INSERT_ID() as id"))
new_id = result.scalar()
return {"id": new_id, "message": "复制成功"}

File diff suppressed because it is too large Load Diff

View File

@@ -17,13 +17,6 @@ router = APIRouter(prefix="/tenant-apps", tags=["应用配置"])
# Schemas
class CustomConfigItem(BaseModel):
"""自定义配置项"""
key: str # 配置键
value: str # 配置值
remark: Optional[str] = None # 备注说明
class TenantAppCreate(BaseModel):
tenant_id: str
app_code: str = "tools"
@@ -32,7 +25,6 @@ class TenantAppCreate(BaseModel):
access_token: Optional[str] = None # 如果不传则自动生成
allowed_origins: Optional[List[str]] = None
allowed_tools: Optional[List[str]] = None
custom_configs: Optional[List[CustomConfigItem]] = None # 自定义配置
class TenantAppUpdate(BaseModel):
@@ -41,7 +33,6 @@ class TenantAppUpdate(BaseModel):
access_token: Optional[str] = None
allowed_origins: Optional[List[str]] = None
allowed_tools: Optional[List[str]] = None
custom_configs: Optional[List[CustomConfigItem]] = None # 自定义配置
status: Optional[int] = None
@@ -120,7 +111,6 @@ async def create_tenant_app(
access_token=access_token,
allowed_origins=json.dumps(data.allowed_origins) if data.allowed_origins else None,
allowed_tools=json.dumps(data.allowed_tools) if data.allowed_tools else None,
custom_configs=json.dumps([c.model_dump() for c in data.custom_configs], ensure_ascii=False) if data.custom_configs else None,
status=1
)
db.add(app)
@@ -149,14 +139,6 @@ async def update_tenant_app(
update_data['allowed_origins'] = json.dumps(update_data['allowed_origins']) if update_data['allowed_origins'] else None
if 'allowed_tools' in update_data:
update_data['allowed_tools'] = json.dumps(update_data['allowed_tools']) if update_data['allowed_tools'] else None
if 'custom_configs' in update_data:
if update_data['custom_configs']:
update_data['custom_configs'] = json.dumps(
[c.model_dump() if hasattr(c, 'model_dump') else c for c in update_data['custom_configs']],
ensure_ascii=False
)
else:
update_data['custom_configs'] = None
for key, value in update_data.items():
setattr(app, key, value)
@@ -182,27 +164,6 @@ async def delete_tenant_app(
return {"success": True}
@router.get("/{app_id}/token")
async def get_token(
app_id: int,
user: User = Depends(require_operator),
db: Session = Depends(get_db)
):
"""获取真实的 access_token仅管理员可用"""
app = db.query(TenantApp).filter(TenantApp.id == app_id).first()
if not app:
raise HTTPException(status_code=404, detail="应用配置不存在")
# 获取应用的 base_url
app_info = db.query(App).filter(App.app_code == app.app_code).first()
base_url = app_info.base_url if app_info else ""
return {
"access_token": app.access_token,
"base_url": base_url
}
@router.post("/{app_id}/regenerate-token")
async def regenerate_token(
app_id: int,
@@ -246,7 +207,6 @@ def format_tenant_app(app: TenantApp, mask_secret: bool = True, db: Session = No
"access_token": "******" if mask_secret and app.access_token else app.access_token,
"allowed_origins": json.loads(app.allowed_origins) if app.allowed_origins else [],
"allowed_tools": json.loads(app.allowed_tools) if app.allowed_tools else [],
"custom_configs": json.loads(app.custom_configs) if app.custom_configs else [],
"status": app.status,
"created_at": app.created_at,
"updated_at": app.updated_at

View File

@@ -1,399 +1,308 @@
"""定时任务调度服务"""
import asyncio
import logging
from datetime import datetime
from typing import Optional, List, Dict, Any
"""定时任务调度服务"""
import json
import httpx
import asyncio
from datetime import datetime
from typing import Optional
from apscheduler.schedulers.asyncio import AsyncIOScheduler
from apscheduler.triggers.cron import CronTrigger
from sqlalchemy import text
from sqlalchemy.orm import Session
from ..database import SessionLocal
from ..models.scheduled_task import ScheduledTask, TaskLog
from .script_executor import ScriptExecutor
class SchedulerService:
"""调度服务 - 管理定时任务的调度和执行"""
_instance: Optional['SchedulerService'] = None
_scheduler: Optional[AsyncIOScheduler] = None
def __new__(cls):
if cls._instance is None:
cls._instance = super().__new__(cls)
return cls._instance
def __init__(self):
if self._scheduler is None:
self._scheduler = AsyncIOScheduler(timezone='Asia/Shanghai')
@property
def scheduler(self) -> AsyncIOScheduler:
return self._scheduler
def start(self):
"""启动调度器并加载所有任务"""
if not self._scheduler.running:
self._scheduler.start()
self._load_all_tasks()
print("调度器已启动")
def shutdown(self):
"""关闭调度器"""
if self._scheduler.running:
self._scheduler.shutdown()
print("调度器已关闭")
def _load_all_tasks(self):
"""从数据库加载所有启用的任务"""
db = SessionLocal()
try:
tasks = db.query(ScheduledTask).filter(ScheduledTask.status == 1).all()
for task in tasks:
self._add_task_to_scheduler(task)
print(f"已加载 {len(tasks)} 个定时任务")
finally:
db.close()
def _add_task_to_scheduler(self, task: ScheduledTask):
"""将任务添加到调度器"""
job_id = f"task_{task.id}"
# 移除已存在的任务
if self._scheduler.get_job(job_id):
self._scheduler.remove_job(job_id)
if task.schedule_type == 'cron' and task.cron_expression:
# CRON模式
try:
trigger = CronTrigger.from_crontab(task.cron_expression, timezone='Asia/Shanghai')
self._scheduler.add_job(
self._execute_task,
trigger,
id=job_id,
args=[task.id],
replace_existing=True
)
except Exception as e:
print(f"任务 {task.id} CRON表达式解析失败: {e}")
elif task.schedule_type == 'simple' and task.time_points:
# 简单模式 - 多个时间点
try:
time_points = json.loads(task.time_points)
for i, time_point in enumerate(time_points):
hour, minute = map(int, time_point.split(':'))
sub_job_id = f"{job_id}_{i}"
self._scheduler.add_job(
self._execute_task,
CronTrigger(hour=hour, minute=minute, timezone='Asia/Shanghai'),
id=sub_job_id,
args=[task.id],
replace_existing=True
)
except Exception as e:
print(f"任务 {task.id} 时间点解析失败: {e}")
def add_task(self, task_id: int):
"""添加或更新任务调度"""
db = SessionLocal()
try:
task = db.query(ScheduledTask).filter(ScheduledTask.id == task_id).first()
if task and task.status == 1:
self._add_task_to_scheduler(task)
finally:
db.close()
def remove_task(self, task_id: int):
"""移除任务调度"""
job_id = f"task_{task_id}"
# 移除主任务
if self._scheduler.get_job(job_id):
self._scheduler.remove_job(job_id)
# 移除简单模式的子任务
for i in range(24): # 最多24个时间点
sub_job_id = f"{job_id}_{i}"
if self._scheduler.get_job(sub_job_id):
self._scheduler.remove_job(sub_job_id)
async def _execute_task(self, task_id: int):
"""执行任务(带重试)"""
db = SessionLocal()
try:
task = db.query(ScheduledTask).filter(ScheduledTask.id == task_id).first()
if not task:
return
max_retries = task.retry_count or 0
retry_interval = task.retry_interval or 60
for attempt in range(max_retries + 1):
success, output, error = await self._execute_task_once(db, task)
if success:
return
# 如果还有重试机会
if attempt < max_retries:
print(f"任务 {task_id} 执行失败,{retry_interval}秒后重试 ({attempt + 1}/{max_retries})")
await asyncio.sleep(retry_interval)
else:
# 最后一次失败,发送告警
if task.alert_on_failure and task.alert_webhook:
await self._send_alert(task, error)
finally:
db.close()
async def _execute_task_once(self, db: Session, task: ScheduledTask):
"""执行一次任务"""
trace_id = f"{int(datetime.now().timestamp())}-{task.id}"
started_at = datetime.now()
# 创建日志记录
log = TaskLog(
task_id=task.id,
tenant_id=task.tenant_id,
trace_id=trace_id,
status='running',
started_at=started_at
)
db.add(log)
db.commit()
db.refresh(log)
success = False
output = ''
error = ''
try:
# 解析输入参数
params = {}
if task.input_params:
try:
params = json.loads(task.input_params)
except:
pass
if task.task_type == 'webhook':
success, output, error = await self._execute_webhook(task)
else:
success, output, error = await self._execute_script(db, task, trace_id, params)
except Exception as e:
error = str(e)
# 更新日志
finished_at = datetime.now()
duration_ms = int((finished_at - started_at).total_seconds() * 1000)
log.status = 'success' if success else 'failed'
log.finished_at = finished_at
log.duration_ms = duration_ms
log.output = output[:10000] if output else None # 限制长度
log.error = error[:5000] if error else None
# 更新任务状态
task.last_run_at = finished_at
task.last_run_status = 'success' if success else 'failed'
db.commit()
return success, output, error
async def _execute_webhook(self, task: ScheduledTask):
"""执行Webhook任务"""
try:
headers = {}
if task.webhook_headers:
headers = json.loads(task.webhook_headers)
body = {}
if task.input_params:
body = json.loads(task.input_params)
async with httpx.AsyncClient(timeout=30) as client:
if task.webhook_method.upper() == 'GET':
response = await client.get(task.webhook_url, headers=headers, params=body)
else:
response = await client.post(task.webhook_url, headers=headers, json=body)
response.raise_for_status()
return True, response.text[:5000], ''
except Exception as e:
return False, '', str(e)
async def _execute_script(self, db: Session, task: ScheduledTask, trace_id: str, params: dict):
"""执行脚本任务"""
if not task.script_content:
return False, '', '脚本内容为空'
executor = ScriptExecutor(db)
success, output, error = executor.execute(
script_content=task.script_content,
task_id=task.id,
tenant_id=task.tenant_id,
trace_id=trace_id,
params=params,
timeout=task.script_timeout or 300
)
return success, output, error
async def _send_alert(self, task: ScheduledTask, error: str):
"""发送失败告警"""
if not task.alert_webhook:
return
content = f"""### 定时任务执行失败告警
**任务名称**: {task.task_name}
**任务ID**: {task.id}
**租户**: {task.tenant_id or '全局'}
**失败时间**: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}
**错误信息**:
```
{error[:500] if error else '未知错误'}
```"""
try:
# 判断是钉钉还是企微
if 'dingtalk' in task.alert_webhook or 'oapi.dingtalk.com' in task.alert_webhook:
payload = {
"msgtype": "markdown",
"markdown": {"title": "任务失败告警", "text": content}
}
else:
payload = {
"msgtype": "markdown",
"markdown": {"content": content}
}
async with httpx.AsyncClient(timeout=10) as client:
await client.post(task.alert_webhook, json=payload)
except Exception as e:
print(f"发送告警失败: {e}")
async def run_task_now(self, task_id: int) -> dict:
"""立即执行任务(手动触发)"""
db = SessionLocal()
try:
task = db.query(ScheduledTask).filter(ScheduledTask.id == task_id).first()
if not task:
return {"success": False, "error": "任务不存在"}
# 解析参数
params = {}
if task.input_params:
try:
params = json.loads(task.input_params)
except:
pass
success, output, error = await self._execute_task_once(db, task)
return {
"success": success,
"output": output,
"error": error
}
finally:
db.close()
logger = logging.getLogger(__name__)
# 全局调度器实例
scheduler: Optional[AsyncIOScheduler] = None
def get_scheduler() -> AsyncIOScheduler:
"""获取调度器实例"""
global scheduler
if scheduler is None:
scheduler = AsyncIOScheduler(timezone="Asia/Shanghai")
return scheduler
def get_db_session() -> Session:
"""获取数据库会话"""
return SessionLocal()
async def send_alert(webhook: str, task_name: str, error_message: str):
"""发送失败告警通知"""
try:
# 自动判断钉钉或企微
if "dingtalk" in webhook or "oapi.dingtalk.com" in webhook:
data = {
"msgtype": "markdown",
"markdown": {
"title": "定时任务执行失败",
"text": f"### ⚠️ 定时任务执行失败\n\n**任务名称**{task_name}\n\n**错误信息**{error_message[:500]}\n\n**时间**{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}"
}
}
else:
# 企微格式
data = {
"msgtype": "markdown",
"markdown": {
"content": f"### ⚠️ 定时任务执行失败\n\n**任务名称**{task_name}\n\n**错误信息**{error_message[:500]}\n\n**时间**{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}"
}
}
async with httpx.AsyncClient(timeout=30.0) as client:
await client.post(webhook, json=data)
logger.info(f"Alert sent for task {task_name}")
except Exception as e:
logger.warning(f"Failed to send alert: {e}")
async def execute_task_with_retry(task_id: int, retry_count: int = 0, max_retries: int = 0, retry_interval: int = 60):
"""带重试的任务执行"""
success = await execute_task_once(task_id)
if not success and retry_count < max_retries:
logger.info(f"Task {task_id} failed, scheduling retry {retry_count + 1}/{max_retries} in {retry_interval}s")
await asyncio.sleep(retry_interval)
await execute_task_with_retry(task_id, retry_count + 1, max_retries, retry_interval)
elif not success:
# 所有重试都失败,发送告警
db = get_db_session()
try:
result = db.execute(
text("SELECT task_name, alert_on_failure, alert_webhook, last_run_message FROM platform_scheduled_tasks WHERE id = :id"),
{"id": task_id}
)
task = result.mappings().first()
if task and task["alert_on_failure"] and task["alert_webhook"]:
await send_alert(task["alert_webhook"], task["task_name"], task["last_run_message"] or "未知错误")
finally:
db.close()
async def execute_task(task_id: int):
"""执行定时任务入口(处理重试配置)"""
db = get_db_session()
try:
result = db.execute(
text("SELECT retry_count, retry_interval FROM platform_scheduled_tasks WHERE id = :id"),
{"id": task_id}
)
task = result.mappings().first()
if task:
max_retries = task.get("retry_count", 0) or 0
retry_interval = task.get("retry_interval", 60) or 60
await execute_task_with_retry(task_id, 0, max_retries, retry_interval)
else:
await execute_task_once(task_id)
finally:
db.close()
async def execute_task_once(task_id: int) -> bool:
"""执行一次定时任务,返回是否成功"""
db = get_db_session()
log_id = None
success = False
try:
# 1. 查询任务配置
result = db.execute(
text("SELECT * FROM platform_scheduled_tasks WHERE id = :id AND is_enabled = 1"),
{"id": task_id}
)
task = result.mappings().first()
if not task:
logger.warning(f"Task {task_id} not found or disabled")
return True # 不需要重试
# 2. 更新任务状态为运行中
db.execute(
text("UPDATE platform_scheduled_tasks SET last_run_status = 'running', last_run_at = NOW() WHERE id = :id"),
{"id": task_id}
)
db.commit()
# 3. 创建执行日志
db.execute(
text("""
INSERT INTO platform_task_logs (task_id, tenant_id, started_at, status)
VALUES (:task_id, :tenant_id, NOW(), 'running')
"""),
{"task_id": task_id, "tenant_id": task["tenant_id"]}
)
db.commit()
# 获取刚插入的日志ID
result = db.execute(text("SELECT LAST_INSERT_ID() as id"))
log_id = result.scalar()
# 生成 trace_id
trace_id = f"task_{task_id}_{datetime.now().strftime('%Y%m%d%H%M%S%f')}"
# 4. 根据执行类型分发
execution_type = task.get("execution_type", "webhook")
if execution_type == "script":
# 脚本执行模式
from .script_executor import execute_script as run_script
script_content = task.get("script_content", "")
if not script_content:
status = "failed"
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(
text("""
UPDATE platform_task_logs
SET finished_at = NOW(), status = :status, response_code = :code,
response_body = :body, error_message = :error
WHERE id = :id
"""),
{
"id": log_id,
"status": status,
"code": response_code,
"body": response_body,
"error": error_message
}
)
# 6. 更新任务状态
db.execute(
text("""
UPDATE platform_scheduled_tasks
SET last_run_status = :status, last_run_message = :message
WHERE id = :id
"""),
{
"id": task_id,
"status": status,
"message": error_message or "执行成功"
}
)
db.commit()
logger.info(f"Task {task_id} executed with status: {status}")
success = (status == "success")
except Exception as e:
logger.error(f"Task {task_id} execution error: {str(e)}")
success = False
# 更新失败状态
try:
if log_id:
db.execute(
text("""
UPDATE platform_task_logs
SET finished_at = NOW(), status = 'failed', error_message = :error
WHERE id = :id
"""),
{"id": log_id, "error": str(e)[:1000]}
)
db.execute(
text("""
UPDATE platform_scheduled_tasks
SET last_run_status = 'failed', last_run_message = :message
WHERE id = :id
"""),
{"id": task_id, "message": str(e)[:500]}
)
db.commit()
except Exception:
pass
finally:
db.close()
return success
def add_task_to_scheduler(task: Dict[str, Any]):
"""将任务添加到调度器"""
global scheduler
if scheduler is None:
return
task_id = task["id"]
schedule_type = task["schedule_type"]
# 先移除已有的任务(如果存在)
remove_task_from_scheduler(task_id)
if schedule_type == "cron":
# CRON 模式
cron_expr = task["cron_expression"]
if cron_expr:
try:
trigger = CronTrigger.from_crontab(cron_expr, timezone="Asia/Shanghai")
scheduler.add_job(
execute_task,
trigger,
args=[task_id],
id=f"task_{task_id}_cron",
replace_existing=True
)
logger.info(f"Added cron task {task_id}: {cron_expr}")
except Exception as e:
logger.error(f"Failed to add cron task {task_id}: {e}")
else:
# 简单模式 - 多个时间点
time_points = task.get("time_points") or []
if isinstance(time_points, str):
import json
time_points = json.loads(time_points)
for i, time_point in enumerate(time_points):
try:
hour, minute = time_point.split(":")
trigger = CronTrigger(
hour=int(hour),
minute=int(minute),
timezone="Asia/Shanghai"
)
scheduler.add_job(
execute_task,
trigger,
args=[task_id],
id=f"task_{task_id}_time_{i}",
replace_existing=True
)
logger.info(f"Added simple task {task_id} at {time_point}")
except Exception as e:
logger.error(f"Failed to add time point {time_point} for task {task_id}: {e}")
def remove_task_from_scheduler(task_id: int):
"""从调度器移除任务"""
global scheduler
if scheduler is None:
return
# 移除所有相关的 job
jobs_to_remove = []
for job in scheduler.get_jobs():
if job.id.startswith(f"task_{task_id}_"):
jobs_to_remove.append(job.id)
for job_id in jobs_to_remove:
try:
scheduler.remove_job(job_id)
logger.info(f"Removed job {job_id}")
except Exception as e:
logger.warning(f"Failed to remove job {job_id}: {e}")
def load_all_tasks():
"""从数据库加载所有启用的任务"""
db = get_db_session()
try:
result = db.execute(
text("SELECT * FROM platform_scheduled_tasks WHERE is_enabled = 1")
)
tasks = result.mappings().all()
for task in tasks:
add_task_to_scheduler(dict(task))
logger.info(f"Loaded {len(tasks)} scheduled tasks")
finally:
db.close()
def start_scheduler():
"""启动调度器"""
global scheduler
scheduler = get_scheduler()
# 加载所有任务
load_all_tasks()
# 启动调度器
if not scheduler.running:
scheduler.start()
logger.info("Scheduler started")
def shutdown_scheduler():
"""关闭调度器"""
global scheduler
if scheduler and scheduler.running:
scheduler.shutdown(wait=False)
logger.info("Scheduler shutdown")
def reload_task(task_id: int):
"""重新加载单个任务"""
db = get_db_session()
try:
result = db.execute(
text("SELECT * FROM platform_scheduled_tasks WHERE id = :id"),
{"id": task_id}
)
task = result.mappings().first()
if task and task["is_enabled"]:
add_task_to_scheduler(dict(task))
else:
remove_task_from_scheduler(task_id)
finally:
db.close()
scheduler_service = SchedulerService()

View File

@@ -1,262 +1,246 @@
"""脚本执行器 - 安全执行Python脚本"""
import asyncio
import io
import logging
import sys
import traceback
from contextlib import redirect_stdout, redirect_stderr
from io import StringIO
from typing import Any, Dict, Optional, Tuple
from datetime import datetime
from typing import Any, Dict
from sqlalchemy.orm import Session
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',
'os', 'subprocess', 'shutil', 'pathlib',
'socket', 'ftplib', 'telnetlib', 'smtplib',
'pickle', 'shelve', 'marshal',
'ctypes', 'multiprocessing',
'__builtins__', 'builtins',
'importlib', 'imp',
'code', 'codeop', 'compile',
}
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 = [
ALLOWED_BUILTINS = {
'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',
'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',
]
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',
'Exception', 'BaseException', 'ValueError', 'TypeError', 'KeyError',
'IndexError', 'AttributeError', 'RuntimeError', 'StopIteration',
}
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))}")
class ScriptExecutor:
"""脚本执行器"""
return __builtins__['__import__'](name, *args, **kwargs)
def __init__(self, db: Session):
self.db = db
safe_builtins['__import__'] = safe_import
return safe_builtins
async def execute_script(
task_id: int,
tenant_id: str,
def execute(
self,
script_content: str,
trace_id: str = None
) -> ScriptExecutionResult:
"""
执行 Python 脚本
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]:
"""执行脚本
Args:
script_content: Python脚本内容
task_id: 任务ID
tenant_id: 租户ID
script_content: 脚本内容
trace_id: 追踪ID
params: 输入参数
timeout: 超时秒数
Returns:
ScriptExecutionResult: 执行结果
(success, output, error)
"""
start_time = datetime.now()
sdk = None
try:
# 创建SDK实例
sdk = ScriptSDK(tenant_id, task_id, trace_id)
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}"
# 准备执行环境
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'),
}
safe_globals = self._create_safe_globals(sdk)
# 捕获输出
stdout = io.StringIO()
stderr = io.StringIO()
old_stdout = sys.stdout
old_stderr = sys.stderr
stdout_capture = StringIO()
stderr_capture = StringIO()
sdk.log("脚本开始执行")
try:
sys.stdout = stdout_capture
sys.stderr = stderr_capture
# 编译并执行脚本
try:
# 编译脚本
code = compile(script_content, '<script>', 'exec')
compiled = compile(script_content, '<script>', 'exec')
exec(compiled, safe_globals)
# 执行(带超时)
async def run_script():
with redirect_stdout(stdout), redirect_stderr(stderr):
exec(code, script_globals)
# 获取输出
stdout_output = stdout_capture.getvalue()
sdk_output = sdk.get_output()
await asyncio.wait_for(run_script(), timeout=SCRIPT_TIMEOUT)
# 合并输出
output = '\n'.join(filter(None, [sdk_output, stdout_output]))
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
)
return True, output, ''
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
)
error_msg = f"{type(e).__name__}: {str(e)}\n{traceback.format_exc()}"
return False, sdk.get_output(), error_msg
finally:
# 清理资源
if sdk:
sdk.cleanup()
sys.stdout = old_stdout
sys.stderr = old_stderr
async def test_script(
tenant_id: str,
script_content: str
) -> ScriptExecutionResult:
"""
测试执行脚本(不记录日志到数据库)
Args:
tenant_id: 租户 ID
script_content: 脚本内容
def _check_script_safety(self, script_content: str) -> Optional[str]:
"""检查脚本安全性
Returns:
ScriptExecutionResult: 执行结果
错误消息如果安全则返回None
"""
return await execute_script(
task_id=0, # 测试用 ID
tenant_id=tenant_id,
# 检查危险导入
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
from datetime import datetime, date, timedelta
from urllib.parse import urlencode, quote, unquote
# 安全的内置函数
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__}
# 添加常用异常
safe_builtins['Exception'] = Exception
safe_builtins['ValueError'] = ValueError
safe_builtins['TypeError'] = TypeError
safe_builtins['KeyError'] = KeyError
safe_builtins['IndexError'] = IndexError
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,
'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,
"logs": [...]
}
"""
start_time = datetime.now()
success, output, error = self.execute(
script_content=script_content,
trace_id=f"test_{datetime.now().strftime('%Y%m%d%H%M%S')}"
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
}

View File

@@ -1,79 +1,113 @@
"""脚本执行 SDK - 提供给 Python 脚本使用的内置能力"""
"""脚本执行SDK - Python脚本提供内置功能"""
import json
import logging
import os
import httpx
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
"""脚本SDK - 提供AI、通知、数据库、HTTP、变量存储等功能"""
提供以下能力:
- AI 大模型调用
- 钉钉/企微通知
- 数据库查询(只读)
- HTTP 请求
- 变量存储(跨执行持久化)
- 日志记录
- 多租户遍历
- 密钥管理
"""
def __init__(self, tenant_id: str, task_id: int, trace_id: str = None):
self.tenant_id = tenant_id
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.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
self._tenants_cache: Optional[List[Dict]] = None # 租户列表缓存
self.tenant_id = tenant_id
self.trace_id = trace_id
self.params = params or {}
def _get_db(self) -> Session:
"""获取数据库会话"""
if self._db is None:
self._db = SessionLocal()
return self._db
self._logs: List[Dict] = []
self._output: List[str] = []
self._tenants_cache: Dict = {}
def _close_db(self):
"""关闭数据库会话"""
if self._db:
self._db.close()
self._db = None
# 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')
# ============ AI 服务 ============
# ==================== 参数获取 ====================
async def ai_chat(
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: str = None,
model: str = "gemini-2.5-flash",
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: 温度,默认 0.7
max_tokens: 最大 token 数,默认 2000
system: 系统提示词
model: 模型名称默认gemini-2.5-flash
temperature: 温度参数
max_tokens: 最大token
Returns:
AI 生成的文本
AI响应内容
"""
# 使用 4sapi 作为 AI 服务
api_key = "sk-9yMCXjRGANbacz20kJY8doSNy6Rf446aYwmgGIuIXQ7DAyBw"
base_url = "https://4sapi.com/v1"
model = model or self._ai_model
messages = []
if system:
@@ -81,11 +115,11 @@ class ScriptSDK:
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",
with httpx.Client(timeout=60) as client:
response = client.post(
f"{self._ai_base_url}/chat/completions",
headers={
"Authorization": f"Bearer {api_key}",
"Authorization": f"Bearer {self._ai_api_key}",
"Content-Type": "application/json"
},
json={
@@ -97,515 +131,349 @@ class ScriptSDK:
)
response.raise_for_status()
data = response.json()
content = data["choices"][0]["message"]["content"]
self.log(f"AI 调用成功,模型: {model},响应长度: {len(content)}")
content = data['choices'][0]['message']['content']
self.log(f"AI调用成功: {len(content)} 字符")
return content
except Exception as e:
self.log(f"AI 调用失败: {str(e)}", level="ERROR")
self.log(f"AI调用失败: {str(e)}", '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:
"""
发送钉钉群消息
def dingtalk(self, webhook: str, content: str, title: Optional[str] = None, at_all: bool = False) -> bool:
"""发送钉钉消息
Args:
webhook: 钉钉机器人 Webhook URL
content: 消息内容
msg_type: 消息类型text 或 markdown
at_mobiles: @的手机号列表
webhook: 钉钉机器人webhook地址
content: 消息内容支持Markdown
title: 消息标题
at_all: 是否@所有人
Returns:
是否发送成功
"""
try:
if msg_type == "text":
data = {
"msgtype": "text",
"text": {"content": content},
"at": {
"atMobiles": at_mobiles or [],
"isAtAll": at_all
}
}
else:
data = {
payload = {
"msgtype": "markdown",
"markdown": {
"title": content[:20] if len(content) > 20 else content,
"text": content
"title": title or "通知",
"text": content + ("\n@所有人" if at_all else "")
},
"at": {
"atMobiles": at_mobiles or [],
"isAtAll": at_all
}
"at": {"isAtAll": at_all}
}
async with httpx.AsyncClient(timeout=30.0) as client:
response = await client.post(webhook, json=data)
with httpx.Client(timeout=10) as client:
response = client.post(webhook, json=payload)
response.raise_for_status()
result = response.json()
if result.get("errcode") == 0:
self.log(f"钉钉消息发送成功")
return True
else:
self.log(f"钉钉消息发送失败: {result.get('errmsg')}", level="ERROR")
return False
success = result.get('errcode') == 0
self.log(f"钉钉消息发送{'成功' if success else '失败'}")
return success
except Exception as e:
self.log(f"钉钉消息发送异常: {str(e)}", level="ERROR")
self.log(f"钉钉消息发送失败: {str(e)}", 'ERROR')
return False
async def send_wecom(
self,
webhook: str,
content: str,
msg_type: str = "text"
) -> bool:
"""
发送企业微信群消息
def wecom(self, webhook: str, content: str, msg_type: str = 'markdown') -> bool:
"""发送企业微信消息
Args:
webhook: 企微机器人 Webhook URL
webhook: 企微机器人webhook地址
content: 消息内容
msg_type: 消息类型text markdown
msg_type: 消息类型 (text, markdown)
Returns:
是否发送成功
"""
try:
if msg_type == "text":
data = {
"msgtype": "text",
"text": {"content": content}
}
else:
data = {
if msg_type == 'markdown':
payload = {
"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
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)}", level="ERROR")
self.log(f"企微消息发送失败: {str(e)}", 'ERROR')
return False
# ============ 数据库查询 ============
# ==================== HTTP 请求 ====================
def db_query(self, sql: str, params: Dict[str, Any] = None) -> List[Dict]:
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": "..."}
"""
执行 SQL 查询(只读)
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: 查询参数
sql: SQL语句必须是SELECT
params: 参数字典
Returns:
查询结果列表
"""
# 安全检查:只允许 SELECT
sql_upper = sql.strip().upper()
if not sql_upper.startswith("SELECT"):
raise ValueError("只允许 SELECT 查询")
if not sql_upper.startswith('SELECT'):
raise ValueError("只允许执行SELECT查询")
forbidden = ["INSERT", "UPDATE", "DELETE", "DROP", "CREATE", "ALTER", "TRUNCATE"]
# 禁止危险操作
forbidden = ['INSERT', 'UPDATE', 'DELETE', 'DROP', 'TRUNCATE', 'ALTER', 'CREATE']
for word in forbidden:
if word in sql_upper:
raise ValueError(f"禁止使用 {word} 语句")
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)} 条记录")
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)}", level="ERROR")
self.log(f"SQL查询失败: {str(e)}", '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()
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:
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])
return json.loads(var.var_value)
except:
return row[0]
return default
except Exception as e:
self.log(f"获取变量失败: {str(e)}", level="ERROR")
return var.var_value
return default
def set_var(self, key: str, value: Any) -> bool:
"""
存储变量(跨执行持久化)
def set_var(self, key: str, value: Any) -> None:
"""设置持久化变量
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
from ..models.scheduled_task import ScriptVar
# 使用 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
var = self.db.query(ScriptVar).filter(
ScriptVar.task_id == self.task_id,
ScriptVar.tenant_id == self.tenant_id,
ScriptVar.var_key == key
).first()
def delete_var(self, key: str) -> bool:
"""
删除变量
value_json = json.dumps(value, ensure_ascii=False)
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 get_tenants(self, app_code: str = None) -> List[Dict]:
"""
获取租户列表(用于多租户任务遍历)
Args:
app_code: 应用代码(可选),筛选订阅了该应用的租户
Returns:
租户列表 [{"code": "xxx", "name": "租户名", "custom_configs": {...}}]
"""
db = self._get_db()
try:
if app_code:
# 筛选订阅了指定应用的租户
result = db.execute(
text("""
SELECT DISTINCT t.code, t.name, ta.custom_configs
FROM platform_tenants t
INNER JOIN platform_tenant_apps ta ON t.code = ta.tenant_id
WHERE ta.app_code = :app_code AND t.status = 1
"""),
{"app_code": app_code}
)
if var:
var.var_value = value_json
else:
# 获取所有启用的租户
result = db.execute(
text("SELECT code, name FROM platform_tenants WHERE status = 1")
var = ScriptVar(
task_id=self.task_id,
tenant_id=self.tenant_id,
var_key=key,
var_value=value_json
)
self.db.add(var)
tenants = []
for row in result.mappings().all():
tenant = dict(row)
# 解析 custom_configs
if "custom_configs" in tenant and tenant["custom_configs"]:
try:
tenant["custom_configs"] = json.loads(tenant["custom_configs"])
except:
pass
tenants.append(tenant)
self.db.commit()
self.log(f"变量 {key} 已保存")
self.log(f"获取租户列表成功,共 {len(tenants)} 个租户")
return tenants
except Exception as e:
self.log(f"获取租户列表失败: {str(e)}", level="ERROR")
return []
def del_var(self, key: str) -> bool:
"""删除持久化变量"""
from ..models.scheduled_task import ScriptVar
def get_tenant_config(self, tenant_id: str, app_code: str, key: str = None) -> Any:
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: 配置项键名(可选,不传返回全部配置)
key: 配置(可选,不提供则返回所有配置)
Returns:
配置值或配置字典
"""
db = self._get_db()
try:
result = db.execute(
text("""
SELECT custom_configs FROM platform_tenant_apps
WHERE tenant_id = :tenant_id AND app_code = :app_code
"""),
{"tenant_id": tenant_id, "app_code": app_code}
)
row = result.first()
from ..models.tenant_app import TenantApp
if not row or not row[0]:
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 {}
try:
configs = json.loads(row[0])
except:
# 解析 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
except Exception as e:
self.log(f"获取租户配置失败: {str(e)}", level="ERROR")
return None if key else {}
def get_all_tenant_configs(self, app_code: str) -> List[Dict]:
"""
获取所有租户的应用配置(便捷方法,用于批量操作)
"""获取所有租户的应用配置
Args:
app_code: 应用代码
Returns:
[{"tenant_id": "xxx", "tenant_name": "租户名", "configs": {...}}]
[{"tenant_id": ..., "tenant_name": ..., "configs": {...}}, ...]
"""
db = self._get_db()
try:
result = db.execute(
text("""
SELECT t.code as tenant_id, t.name as tenant_name, ta.custom_configs
FROM platform_tenants t
INNER JOIN platform_tenant_apps ta ON t.code = ta.tenant_id
WHERE ta.app_code = :app_code AND t.status = 1
"""),
{"app_code": app_code}
)
from ..models.tenant import Tenant
from ..models.tenant_app import TenantApp
tenants = []
for row in result.mappings().all():
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 row["custom_configs"]:
if hasattr(ta, 'custom_configs') and ta.custom_configs:
try:
configs = json.loads(row["custom_configs"])
configs = json.loads(ta.custom_configs) if isinstance(ta.custom_configs, str) else ta.custom_configs
except:
pass
tenants.append({
"tenant_id": row["tenant_id"],
"tenant_name": row["tenant_name"],
result.append({
"tenant_id": ta.tenant_id,
"tenant_name": tenant.name if tenant else ta.tenant_id,
"configs": configs
})
self.log(f"获取 {app_code} 应用的租户配置,共 {len(tenants)}")
return tenants
except Exception as e:
self.log(f"获取租户配置失败: {str(e)}", level="ERROR")
return []
return result
# ============ 密钥管理 ============
# ==================== 密钥管理 ====================
def get_secret(self, key: str) -> Optional[str]:
"""
获取密钥(优先读取租户级密钥,其次读取全局密钥)
"""获取密钥(优先租户级,其次全局)
Args:
key: 密钥名
key: 密钥名
Returns:
密钥值(如不存在返回 None
密钥值
"""
db = self._get_db()
try:
# 优先查询租户级密钥
result = db.execute(
text("""
SELECT secret_value FROM platform_secrets
WHERE (tenant_id = :tenant_id OR tenant_id IS NULL)
AND secret_key = :key
ORDER BY tenant_id DESC
LIMIT 1
"""),
{"tenant_id": self.tenant_id, "key": key}
)
row = result.first()
from ..models.scheduled_task import Secret
if row:
self.log(f"获取密钥成功: {key}")
return row[0]
# 先查租户级
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
self.log(f"密钥不存在: {key}", level="WARN")
return None
except Exception as e:
self.log(f"获取密钥失败: {str(e)}", level="ERROR")
return None
# 再查全局
secret = self.db.query(Secret).filter(
Secret.tenant_id.is_(None),
Secret.secret_key == key
).first()
# ============ 日志 ============
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()
return secret.secret_value if secret else None

View File

@@ -16,10 +16,9 @@ const menuItems = computed(() => {
{ path: '/apps', title: '应用管理', icon: 'Grid' },
{ path: '/tenant-wechat-apps', title: '企微应用', icon: 'ChatDotRound' },
{ path: '/app-config', title: '租户订阅', icon: 'Setting' },
{ path: '/scheduled-tasks', title: '定时任务', icon: 'Clock' },
{ path: '/scripts', title: '脚本管理', icon: 'Tickets' },
{ path: '/stats', title: '统计分析', icon: 'TrendCharts' },
{ path: '/logs', title: '日志查看', icon: 'Document' }
{ path: '/logs', title: '日志查看', icon: 'Document' },
{ path: '/scheduled-tasks', title: '定时任务', icon: 'Clock' }
]
// 管理员才能看到用户管理

View File

@@ -1,142 +0,0 @@
<script setup>
import { ref, onMounted, onBeforeUnmount, watch, nextTick } from 'vue'
import loader from '@monaco-editor/loader'
const props = defineProps({
modelValue: {
type: String,
default: ''
},
language: {
type: String,
default: 'python'
},
theme: {
type: String,
default: 'vs-dark'
},
options: {
type: Object,
default: () => ({})
},
height: {
type: String,
default: '100%'
},
readonly: {
type: Boolean,
default: false
}
})
const emit = defineEmits(['update:modelValue', 'save', 'run'])
const editorContainer = ref(null)
let editor = null
let monaco = null
// 初始化编辑器
async function initEditor() {
if (!editorContainer.value) return
// 加载 Monaco
monaco = await loader.init()
// 创建编辑器
editor = monaco.editor.create(editorContainer.value, {
value: props.modelValue,
language: props.language,
theme: props.theme,
readOnly: props.readonly,
automaticLayout: true,
minimap: { enabled: true },
fontSize: 14,
lineNumbers: 'on',
scrollBeyondLastLine: false,
wordWrap: 'on',
tabSize: 4,
insertSpaces: true,
folding: true,
renderLineHighlight: 'all',
selectOnLineNumbers: true,
roundedSelection: true,
cursorStyle: 'line',
cursorBlinking: 'smooth',
smoothScrolling: true,
...props.options
})
// 监听内容变化
editor.onDidChangeModelContent(() => {
const value = editor.getValue()
emit('update:modelValue', value)
})
// 快捷键
editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS, () => {
emit('save')
})
editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.Enter, () => {
emit('run')
})
}
// 监听值变化
watch(() => props.modelValue, (newVal) => {
if (editor && editor.getValue() !== newVal) {
editor.setValue(newVal || '')
}
})
// 监听主题变化
watch(() => props.theme, (newTheme) => {
if (monaco) {
monaco.editor.setTheme(newTheme)
}
})
// 监听只读变化
watch(() => props.readonly, (newVal) => {
if (editor) {
editor.updateOptions({ readOnly: newVal })
}
})
onMounted(() => {
nextTick(() => {
initEditor()
})
})
onBeforeUnmount(() => {
if (editor) {
editor.dispose()
editor = null
}
})
// 暴露方法
defineExpose({
getEditor: () => editor,
focus: () => editor?.focus(),
format: () => editor?.getAction('editor.action.formatDocument')?.run()
})
</script>
<template>
<div
ref="editorContainer"
class="monaco-editor-container"
:style="{ height }"
></div>
</template>
<style scoped>
.monaco-editor-container {
width: 100%;
border: 1px solid #dcdfe6;
border-radius: 4px;
overflow: hidden;
}
</style>

View File

@@ -53,19 +53,7 @@ const routes = [
path: 'app-config',
name: 'AppConfig',
component: () => import('@/views/app-config/index.vue'),
meta: { title: '租户订阅', icon: 'Setting' }
},
{
path: 'scheduled-tasks',
name: 'ScheduledTasks',
component: () => import('@/views/scheduled-tasks/index.vue'),
meta: { title: '定时任务', icon: 'Clock' }
},
{
path: 'scripts',
name: 'Scripts',
component: () => import('@/views/scripts/index.vue'),
meta: { title: '脚本管理', icon: 'Document' }
meta: { title: '租户应用配置', icon: 'Setting' }
},
{
path: 'stats',
@@ -84,6 +72,12 @@ const routes = [
name: 'Users',
component: () => import('@/views/users/index.vue'),
meta: { title: '用户管理', icon: 'User', role: 'admin' }
},
{
path: 'scheduled-tasks',
name: 'ScheduledTasks',
component: () => import('@/views/scheduled-tasks/index.vue'),
meta: { title: '定时任务', icon: 'Clock' }
}
]
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,6 @@
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Delete, Plus } from '@element-plus/icons-vue'
import api from '@/api'
import { useAuthStore } from '@/stores/auth'
@@ -15,14 +14,6 @@ const query = reactive({
size: 20
})
// 配置项类型选项
const configTypes = [
{ value: 'text', label: '文本输入' },
{ value: 'radio', label: '单选' },
{ value: 'select', label: '下拉选择' },
{ value: 'switch', label: '开关' }
]
// 对话框
const dialogVisible = ref(false)
const dialogTitle = ref('')
@@ -33,8 +24,7 @@ const form = reactive({
app_name: '',
base_url: '',
description: '',
require_jssdk: false,
config_schema: [] // 配置项定义
require_jssdk: false
})
const rules = {
@@ -73,8 +63,7 @@ function handleCreate() {
app_name: '',
base_url: '',
description: '',
require_jssdk: false,
config_schema: []
require_jssdk: false
})
dialogVisible.value = true
}
@@ -87,55 +76,11 @@ function handleEdit(row) {
app_name: row.app_name,
base_url: row.base_url || '',
description: row.description || '',
require_jssdk: row.require_jssdk || false,
config_schema: row.config_schema ? row.config_schema.map(c => ({
...c,
options: c.options || [],
option_labels: c.option_labels || {}
})) : []
require_jssdk: row.require_jssdk || false
})
dialogVisible.value = true
}
// 配置项管理
function addConfigItem() {
form.config_schema.push({
key: '',
label: '',
type: 'text',
options: [],
option_labels: {},
default: '',
placeholder: '',
required: false
})
}
function removeConfigItem(index) {
form.config_schema.splice(index, 1)
}
// 选项管理radio/select 类型)
function addOption(config) {
const optionKey = `option_${config.options.length + 1}`
config.options.push(optionKey)
config.option_labels[optionKey] = ''
}
function removeOption(config, index) {
const optionKey = config.options[index]
config.options.splice(index, 1)
delete config.option_labels[optionKey]
}
function updateOptionKey(config, index, newKey) {
const oldKey = config.options[index]
const oldLabel = config.option_labels[oldKey]
delete config.option_labels[oldKey]
config.options[index] = newKey
config.option_labels[newKey] = oldLabel || ''
}
async function handleSubmit() {
await formRef.value.validate()
@@ -209,14 +154,6 @@ onMounted(() => {
<el-table-column prop="app_code" label="应用代码" width="150" />
<el-table-column prop="app_name" label="应用名称" width="180" />
<el-table-column prop="base_url" label="访问地址" min-width="300" show-overflow-tooltip />
<el-table-column label="配置项" width="90">
<template #default="{ row }">
<el-tag v-if="row.config_schema && row.config_schema.length > 0" type="primary" size="small">
{{ row.config_schema.length }}
</el-tag>
<span v-else style="color: #909399; font-size: 12px">-</span>
</template>
</el-table-column>
<el-table-column label="JS-SDK" width="90">
<template #default="{ row }">
<el-tag :type="row.require_jssdk ? 'warning' : 'info'" size="small">
@@ -254,7 +191,7 @@ onMounted(() => {
</div>
<!-- 编辑对话框 -->
<el-dialog v-model="dialogVisible" :title="dialogTitle" width="800px">
<el-dialog v-model="dialogVisible" :title="dialogTitle" width="550px">
<el-form ref="formRef" :model="form" :rules="rules" label-width="100px">
<el-form-item label="应用代码" prop="app_code">
<el-input v-model="form.app_code" :disabled="!!editingId" placeholder="唯一标识,如: brainstorm" />
@@ -275,69 +212,6 @@ onMounted(() => {
<el-switch v-model="form.require_jssdk" />
<span style="margin-left: 12px; color: #909399; font-size: 12px">开启后租户需关联企微应用</span>
</el-form-item>
<!-- 配置项定义 -->
<el-divider content-position="left">配置项定义</el-divider>
<div class="config-schema-section">
<div class="config-schema-tip">
定义租户订阅时可配置的参数如行业类型提示词等
</div>
<div v-for="(config, index) in form.config_schema" :key="index" class="config-schema-item">
<div class="config-header">
<span class="config-index">#{{ index + 1 }}</span>
<el-button type="danger" :icon="Delete" circle size="small" @click="removeConfigItem(index)" />
</div>
<div class="config-row">
<el-input v-model="config.key" placeholder="配置键(如:industry)" style="width: 140px" />
<el-input v-model="config.label" placeholder="显示标签(如:行业类型)" style="width: 160px" />
<el-select v-model="config.type" placeholder="类型" style="width: 120px">
<el-option v-for="t in configTypes" :key="t.value" :label="t.label" :value="t.value" />
</el-select>
<el-checkbox v-model="config.required">必填</el-checkbox>
</div>
<!-- text 类型显示 placeholder -->
<div v-if="config.type === 'text'" class="config-row" style="margin-top: 8px">
<el-input v-model="config.placeholder" placeholder="输入提示文字" style="width: 300px" />
<el-input v-model="config.default" placeholder="默认值" style="width: 200px" />
</div>
<!-- switch 类型显示默认值 -->
<div v-if="config.type === 'switch'" class="config-row" style="margin-top: 8px">
<span style="color: #606266; margin-right: 8px">默认值</span>
<el-switch v-model="config.default" active-value="true" inactive-value="false" />
</div>
<!-- radio/select 类型显示选项编辑 -->
<div v-if="config.type === 'radio' || config.type === 'select'" class="config-options">
<div class="options-label">选项列表</div>
<div v-for="(opt, optIndex) in config.options" :key="optIndex" class="option-row">
<el-input
:model-value="opt"
@update:model-value="v => updateOptionKey(config, optIndex, v)"
placeholder="选项值(:medical)"
style="width: 140px"
/>
<el-input
v-model="config.option_labels[opt]"
placeholder="显示名(:医美)"
style="width: 140px"
/>
<el-radio v-model="config.default" :value="opt">默认</el-radio>
<el-button type="danger" :icon="Delete" circle size="small" @click="removeOption(config, optIndex)" />
</div>
<el-button type="primary" plain size="small" @click="addOption(config)">
<el-icon><Plus /></el-icon> 添加选项
</el-button>
</div>
</div>
<el-button type="primary" plain @click="addConfigItem" style="margin-top: 12px">
<el-icon><Plus /></el-icon> 添加配置项
</el-button>
</div>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
@@ -351,61 +225,4 @@ onMounted(() => {
.page-tip {
margin-bottom: 16px;
}
/* 配置项定义样式 */
.config-schema-section {
padding: 0 10px;
}
.config-schema-tip {
color: #909399;
font-size: 13px;
margin-bottom: 12px;
}
.config-schema-item {
background: #f5f7fa;
border-radius: 8px;
padding: 12px;
margin-bottom: 12px;
}
.config-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 10px;
}
.config-index {
font-weight: 600;
color: #409eff;
}
.config-row {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.config-options {
margin-top: 10px;
padding: 10px;
background: #fff;
border-radius: 6px;
}
.options-label {
font-size: 13px;
color: #606266;
margin-bottom: 8px;
}
.option-row {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -1,772 +0,0 @@
<script setup>
import { ref, reactive, onMounted, computed, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus, Delete, VideoPlay, Document, FolderOpened, Search, Refresh, CopyDocument } from '@element-plus/icons-vue'
import api from '@/api'
import { useAuthStore } from '@/stores/auth'
import MonacoEditor from '@/components/MonacoEditor.vue'
const authStore = useAuthStore()
// 脚本列表
const loading = ref(false)
const scriptList = ref([])
const categories = ref([])
const total = ref(0)
const query = reactive({
page: 1,
size: 100,
category: '',
keyword: ''
})
// 当前编辑的脚本
const currentScript = ref(null)
const scriptContent = ref('')
const isModified = ref(false)
const saving = ref(false)
const running = ref(false)
// 新建对话框
const createDialogVisible = ref(false)
const createForm = reactive({
name: '',
filename: '',
description: '',
category: '',
script_content: '# 新脚本\n\nlog("Hello World!")\n'
})
// 运行结果
const runResult = ref(null)
const runDialogVisible = ref(false)
// SDK 文档
const sdkDocsVisible = ref(false)
const sdkDocs = ref(null)
// 监听内容变化
watch(scriptContent, (newVal) => {
if (currentScript.value) {
isModified.value = newVal !== currentScript.value.script_content
}
})
// 获取脚本列表
async function fetchScripts() {
loading.value = true
try {
const res = await api.get('/api/scripts', { params: query })
scriptList.value = res.data.items || []
categories.value = res.data.categories || []
total.value = res.data.total || 0
} catch (e) {
console.error(e)
} finally {
loading.value = false
}
}
// 选择脚本
async function selectScript(script) {
// 检查是否有未保存的修改
if (isModified.value && currentScript.value) {
try {
await ElMessageBox.confirm('当前脚本有未保存的修改,是否放弃?', '提示', {
type: 'warning',
confirmButtonText: '放弃',
cancelButtonText: '取消'
})
} catch {
return
}
}
try {
const res = await api.get(`/api/scripts/${script.id}`)
currentScript.value = res.data
scriptContent.value = res.data.script_content || ''
isModified.value = false
} catch (e) {
console.error(e)
}
}
// 保存脚本
async function handleSave() {
if (!currentScript.value) return
saving.value = true
try {
await api.put(`/api/scripts/${currentScript.value.id}`, {
script_content: scriptContent.value
})
currentScript.value.script_content = scriptContent.value
isModified.value = false
ElMessage.success('保存成功')
} catch (e) {
// 错误已在拦截器处理
} finally {
saving.value = false
}
}
// 运行脚本
async function handleRun() {
if (!currentScript.value) return
// 如果有修改,先保存
if (isModified.value) {
await handleSave()
}
running.value = true
runResult.value = null
try {
const res = await api.post(`/api/scripts/${currentScript.value.id}/run`)
runResult.value = res.data
runDialogVisible.value = true
fetchScripts() // 刷新执行状态
} catch (e) {
runResult.value = { success: false, error: e.message || '执行失败' }
runDialogVisible.value = true
} finally {
running.value = false
}
}
// 新建脚本
function handleCreate() {
Object.assign(createForm, {
name: '',
filename: '',
description: '',
category: '',
script_content: '# 新脚本\n\nlog("Hello World!")\n'
})
createDialogVisible.value = true
}
async function submitCreate() {
if (!createForm.name) {
ElMessage.warning('请输入脚本名称')
return
}
try {
const res = await api.post('/api/scripts', createForm)
ElMessage.success('创建成功')
createDialogVisible.value = false
await fetchScripts()
// 自动选中新创建的脚本
const newScript = scriptList.value.find(s => s.id === res.data.id)
if (newScript) {
selectScript(newScript)
}
} catch (e) {
// 错误已在拦截器处理
}
}
// 删除脚本
async function handleDelete() {
if (!currentScript.value) return
await ElMessageBox.confirm(
`确定删除脚本「${currentScript.value.name}」吗?此操作不可恢复。`,
'删除确认',
{ type: 'warning' }
)
try {
await api.delete(`/api/scripts/${currentScript.value.id}`)
ElMessage.success('删除成功')
currentScript.value = null
scriptContent.value = ''
isModified.value = false
fetchScripts()
} catch (e) {
// 错误已在拦截器处理
}
}
// 复制脚本
async function handleCopy() {
if (!currentScript.value) return
try {
const res = await api.post(`/api/scripts/${currentScript.value.id}/copy`)
ElMessage.success('复制成功')
await fetchScripts()
// 自动选中新创建的脚本
const newScript = scriptList.value.find(s => s.id === res.data.id)
if (newScript) {
selectScript(newScript)
}
} catch (e) {
// 错误已在拦截器处理
}
}
// 更新脚本信息
async function handleUpdateInfo() {
if (!currentScript.value) return
const { value } = await ElMessageBox.prompt('请输入新的脚本名称', '修改名称', {
inputValue: currentScript.value.name,
confirmButtonText: '保存',
cancelButtonText: '取消'
})
if (value && value !== currentScript.value.name) {
try {
await api.put(`/api/scripts/${currentScript.value.id}`, { name: value })
currentScript.value.name = value
ElMessage.success('修改成功')
fetchScripts()
} catch (e) {
// 错误已在拦截器处理
}
}
}
// 查看 SDK 文档
async function handleShowSdkDocs() {
try {
const res = await api.get('/api/scheduled-tasks/sdk-docs')
sdkDocs.value = res.data
sdkDocsVisible.value = true
} catch (e) {
ElMessage.error('获取文档失败')
}
}
// 格式化时间
function formatTime(time) {
if (!time) return '-'
return new Date(time).toLocaleString('zh-CN')
}
// 获取状态类型
function getStatusType(status) {
const map = {
success: 'success',
failed: 'danger',
running: 'warning'
}
return map[status] || 'info'
}
onMounted(() => {
fetchScripts()
})
</script>
<template>
<div class="scripts-page">
<!-- 左侧脚本列表 -->
<div class="scripts-sidebar">
<div class="sidebar-header">
<h3>脚本管理</h3>
<el-button v-if="authStore.isOperator" type="primary" size="small" @click="handleCreate">
<el-icon><Plus /></el-icon>
新建
</el-button>
</div>
<!-- 搜索和筛选 -->
<div class="sidebar-filter">
<el-input
v-model="query.keyword"
placeholder="搜索脚本..."
clearable
size="small"
@change="fetchScripts"
>
<template #prefix>
<el-icon><Search /></el-icon>
</template>
</el-input>
<el-select
v-model="query.category"
placeholder="分类"
clearable
size="small"
style="width: 100%; margin-top: 8px"
@change="fetchScripts"
>
<el-option
v-for="cat in categories"
:key="cat"
:label="cat"
:value="cat"
/>
</el-select>
</div>
<!-- 脚本列表 -->
<div class="scripts-list" v-loading="loading">
<div
v-for="script in scriptList"
:key="script.id"
class="script-item"
:class="{ active: currentScript?.id === script.id }"
@click="selectScript(script)"
>
<div class="script-icon">
<el-icon><Document /></el-icon>
</div>
<div class="script-info">
<div class="script-name">{{ script.name }}</div>
<div class="script-meta">
<el-tag v-if="script.category" size="small" type="info">{{ script.category }}</el-tag>
<el-tag
v-if="script.last_run_status"
size="small"
:type="getStatusType(script.last_run_status)"
>
{{ script.last_run_status }}
</el-tag>
</div>
</div>
</div>
<div v-if="!loading && scriptList.length === 0" class="empty-tip">
暂无脚本
</div>
</div>
</div>
<!-- 右侧编辑器区域 -->
<div class="scripts-editor">
<template v-if="currentScript">
<!-- 工具栏 -->
<div class="editor-toolbar">
<div class="toolbar-left">
<span class="script-title" @click="handleUpdateInfo">
{{ currentScript.name }}
<span v-if="isModified" class="modified-dot"></span>
</span>
<span class="script-filename">{{ currentScript.filename }}</span>
</div>
<div class="toolbar-right">
<el-button link size="small" @click="handleShowSdkDocs">
SDK 文档
</el-button>
<el-button
type="primary"
size="small"
:loading="saving"
:disabled="!isModified"
@click="handleSave"
>
保存 (Ctrl+S)
</el-button>
<el-button
type="success"
size="small"
:loading="running"
@click="handleRun"
>
<el-icon><VideoPlay /></el-icon>
运行 (Ctrl+Enter)
</el-button>
<el-button link size="small" @click="handleCopy">
<el-icon><CopyDocument /></el-icon>
复制
</el-button>
<el-button v-if="authStore.isOperator" type="danger" link size="small" @click="handleDelete">
<el-icon><Delete /></el-icon>
删除
</el-button>
</div>
</div>
<!-- Monaco 编辑器 -->
<div class="editor-container">
<MonacoEditor
v-model="scriptContent"
language="python"
theme="vs-dark"
height="100%"
@save="handleSave"
@run="handleRun"
/>
</div>
<!-- 脚本信息 -->
<div class="editor-footer">
<span>创建{{ formatTime(currentScript.created_at) }}</span>
<span>更新{{ formatTime(currentScript.updated_at) }}</span>
<span v-if="currentScript.last_run_at">
上次运行{{ formatTime(currentScript.last_run_at) }}
<el-tag size="small" :type="getStatusType(currentScript.last_run_status)">
{{ currentScript.last_run_status }}
</el-tag>
</span>
</div>
</template>
<!-- 未选择脚本时的占位 -->
<div v-else class="editor-placeholder">
<el-icon :size="64" color="#dcdfe6"><Document /></el-icon>
<p>选择一个脚本开始编辑</p>
<p style="color: #909399; font-size: 12px">或点击左上角"新建"创建新脚本</p>
</div>
</div>
<!-- 新建脚本对话框 -->
<el-dialog v-model="createDialogVisible" title="新建脚本" width="500px">
<el-form :model="createForm" label-width="80px">
<el-form-item label="脚本名称" required>
<el-input v-model="createForm.name" placeholder="如:每日数据同步" />
</el-form-item>
<el-form-item label="文件名">
<el-input v-model="createForm.filename" placeholder="可选,如 daily_sync.py" />
</el-form-item>
<el-form-item label="分类">
<el-select v-model="createForm.category" placeholder="选择或输入分类" filterable allow-create style="width: 100%">
<el-option v-for="cat in categories" :key="cat" :label="cat" :value="cat" />
</el-select>
</el-form-item>
<el-form-item label="描述">
<el-input v-model="createForm.description" type="textarea" :rows="2" placeholder="可选" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="createDialogVisible = false">取消</el-button>
<el-button type="primary" @click="submitCreate">创建</el-button>
</template>
</el-dialog>
<!-- 运行结果对话框 -->
<el-dialog v-model="runDialogVisible" title="运行结果" width="700px">
<div v-if="runResult" class="run-result">
<el-alert
:type="runResult.success ? 'success' : 'error'"
:closable="false"
style="margin-bottom: 16px"
>
<template #title>
{{ runResult.success ? '执行成功' : '执行失败' }}
<span v-if="runResult.execution_time_ms" style="margin-left: 8px; color: #909399">
({{ runResult.execution_time_ms }}ms)
</span>
</template>
</el-alert>
<div v-if="runResult.error" class="result-section">
<h4>错误信息</h4>
<pre class="result-content error">{{ runResult.error }}</pre>
</div>
<div v-if="runResult.output" class="result-section">
<h4>输出</h4>
<pre class="result-content">{{ runResult.output }}</pre>
</div>
<div v-if="runResult.logs && runResult.logs.length" class="result-section">
<h4>日志</h4>
<pre class="result-content logs">{{ runResult.logs.join('\n') }}</pre>
</div>
</div>
<template #footer>
<el-button @click="runDialogVisible = false">关闭</el-button>
</template>
</el-dialog>
<!-- SDK 文档对话框 -->
<el-dialog v-model="sdkDocsVisible" title="SDK 文档" width="800px">
<div v-if="sdkDocs" class="sdk-docs">
<p>{{ sdkDocs.description }}</p>
<h4>可用方法</h4>
<div v-for="method in sdkDocs.methods" :key="method.name" class="sdk-method">
<code>{{ method.name }}</code>
<p>{{ method.description }}</p>
<pre class="sdk-example">{{ method.example }}</pre>
</div>
<h4>示例脚本</h4>
<pre class="sdk-example-script">{{ sdkDocs.example_script }}</pre>
</div>
<template #footer>
<el-button @click="sdkDocsVisible = false">关闭</el-button>
</template>
</el-dialog>
</div>
</template>
<style scoped>
.scripts-page {
display: flex;
height: calc(100vh - 120px);
background: #fff;
border-radius: 8px;
overflow: hidden;
}
/* 左侧边栏 */
.scripts-sidebar {
width: 280px;
border-right: 1px solid #e4e7ed;
display: flex;
flex-direction: column;
background: #fafafa;
}
.sidebar-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px;
border-bottom: 1px solid #e4e7ed;
}
.sidebar-header h3 {
margin: 0;
font-size: 16px;
color: #303133;
}
.sidebar-filter {
padding: 12px 16px;
border-bottom: 1px solid #e4e7ed;
}
.scripts-list {
flex: 1;
overflow-y: auto;
padding: 8px;
}
.script-item {
display: flex;
align-items: center;
padding: 10px 12px;
border-radius: 6px;
cursor: pointer;
transition: all 0.2s;
margin-bottom: 4px;
}
.script-item:hover {
background: #ecf5ff;
}
.script-item.active {
background: #409eff;
color: #fff;
}
.script-item.active .script-meta .el-tag {
background: rgba(255, 255, 255, 0.2);
border-color: rgba(255, 255, 255, 0.3);
color: #fff;
}
.script-icon {
margin-right: 10px;
font-size: 20px;
color: #909399;
}
.script-item.active .script-icon {
color: #fff;
}
.script-info {
flex: 1;
min-width: 0;
}
.script-name {
font-size: 14px;
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.script-meta {
display: flex;
gap: 4px;
margin-top: 4px;
}
.empty-tip {
text-align: center;
color: #909399;
padding: 40px 0;
}
/* 右侧编辑器 */
.scripts-editor {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
}
.editor-toolbar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 16px;
border-bottom: 1px solid #e4e7ed;
background: #fff;
}
.toolbar-left {
display: flex;
align-items: center;
gap: 12px;
}
.script-title {
font-size: 16px;
font-weight: 600;
color: #303133;
cursor: pointer;
}
.script-title:hover {
color: #409eff;
}
.modified-dot {
color: #e6a23c;
margin-left: 4px;
}
.script-filename {
font-size: 12px;
color: #909399;
}
.toolbar-right {
display: flex;
align-items: center;
gap: 8px;
}
.editor-container {
flex: 1;
min-height: 0;
}
.editor-footer {
display: flex;
gap: 24px;
padding: 8px 16px;
font-size: 12px;
color: #909399;
background: #fafafa;
border-top: 1px solid #e4e7ed;
}
.editor-placeholder {
flex: 1;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
color: #909399;
}
.editor-placeholder p {
margin-top: 16px;
font-size: 14px;
}
/* 运行结果 */
.run-result {
max-height: 500px;
overflow-y: auto;
}
.result-section {
margin-bottom: 16px;
}
.result-section h4 {
margin: 0 0 8px;
font-size: 14px;
color: #303133;
}
.result-content {
background: #f5f7fa;
padding: 12px;
border-radius: 4px;
font-size: 13px;
max-height: 200px;
overflow-y: auto;
white-space: pre-wrap;
word-break: break-all;
margin: 0;
}
.result-content.error {
background: #fef0f0;
color: #f56c6c;
}
.result-content.logs {
background: #1e1e1e;
color: #d4d4d4;
}
/* SDK 文档 */
.sdk-docs {
max-height: 60vh;
overflow-y: auto;
}
.sdk-docs h4 {
margin: 16px 0 8px;
color: #303133;
}
.sdk-method {
margin-bottom: 16px;
padding: 12px;
background: #f5f7fa;
border-radius: 8px;
}
.sdk-method code {
display: block;
font-size: 14px;
font-weight: 600;
color: #409eff;
margin-bottom: 8px;
}
.sdk-method p {
margin: 0 0 8px;
color: #606266;
}
.sdk-example {
background: #fff;
padding: 8px;
border-radius: 4px;
font-size: 12px;
margin: 0;
color: #303133;
}
.sdk-example-script {
background: #1e1e1e;
color: #d4d4d4;
padding: 16px;
border-radius: 8px;
font-size: 13px;
line-height: 1.5;
overflow-x: auto;
}
</style>