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

@@ -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