- 新增 platform_scheduled_tasks, platform_task_logs, platform_script_vars, platform_secrets 数据库表 - 实现 ScriptSDK 提供 AI/通知/DB/HTTP/变量存储/参数获取等功能 - 实现安全的脚本执行器,支持沙箱环境和禁止危险操作 - 实现 APScheduler 调度服务,支持简单时间点和 CRON 表达式 - 新增定时任务 API 路由,包含 CRUD、执行、日志、密钥管理 - 新增定时任务前端页面,支持脚本编辑、测试运行、日志查看
This commit is contained in:
@@ -14,12 +14,10 @@ from .routers.wechat import router as wechat_router
|
|||||||
from .routers.alerts import router as alerts_router
|
from .routers.alerts import router as alerts_router
|
||||||
from .routers.cost import router as cost_router
|
from .routers.cost import router as cost_router
|
||||||
from .routers.quota import router as quota_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.tasks import router as tasks_router
|
||||||
from .routers.scripts import router as scripts_router
|
|
||||||
from .middleware import TraceMiddleware, setup_exception_handlers, RequestLoggerMiddleware
|
from .middleware import TraceMiddleware, setup_exception_handlers, RequestLoggerMiddleware
|
||||||
from .middleware.trace import setup_logging
|
from .middleware.trace import setup_logging
|
||||||
from .services.scheduler import start_scheduler, shutdown_scheduler
|
from .services.scheduler import scheduler_service
|
||||||
|
|
||||||
# 配置日志(包含 TraceID)
|
# 配置日志(包含 TraceID)
|
||||||
setup_logging(level=logging.INFO, include_trace=True)
|
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(alerts_router, prefix="/api")
|
||||||
app.include_router(cost_router, prefix="/api")
|
app.include_router(cost_router, prefix="/api")
|
||||||
app.include_router(quota_router, prefix="/api")
|
app.include_router(quota_router, prefix="/api")
|
||||||
app.include_router(tool_configs_router, prefix="/api")
|
app.include_router(tasks_router)
|
||||||
app.include_router(tasks_router, prefix="/api")
|
|
||||||
app.include_router(scripts_router, prefix="/api")
|
|
||||||
|
|
||||||
|
|
||||||
|
# 应用生命周期事件
|
||||||
@app.on_event("startup")
|
@app.on_event("startup")
|
||||||
async def startup_event():
|
async def startup_event():
|
||||||
"""应用启动时初始化调度器"""
|
"""应用启动时启动调度器"""
|
||||||
try:
|
scheduler_service.start()
|
||||||
start_scheduler()
|
|
||||||
logging.info("Scheduler started successfully")
|
|
||||||
except Exception as e:
|
|
||||||
logging.error(f"Failed to start scheduler: {e}")
|
|
||||||
|
|
||||||
|
|
||||||
@app.on_event("shutdown")
|
@app.on_event("shutdown")
|
||||||
async def shutdown_event():
|
async def shutdown_event():
|
||||||
"""应用关闭时停止调度器"""
|
"""应用关闭时关闭调度器"""
|
||||||
try:
|
scheduler_service.shutdown()
|
||||||
shutdown_scheduler()
|
|
||||||
logging.info("Scheduler shutdown successfully")
|
|
||||||
except Exception as e:
|
|
||||||
logging.error(f"Failed to shutdown scheduler: {e}")
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/")
|
@app.get("/")
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ from .stats import AICallEvent, TenantUsageDaily
|
|||||||
from .logs import PlatformLog
|
from .logs import PlatformLog
|
||||||
from .alert import AlertRule, AlertRecord, NotificationChannel
|
from .alert import AlertRule, AlertRecord, NotificationChannel
|
||||||
from .pricing import ModelPricing, TenantBilling
|
from .pricing import ModelPricing, TenantBilling
|
||||||
|
from .scheduled_task import ScheduledTask, TaskLog, ScriptVar, Secret
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"Tenant",
|
"Tenant",
|
||||||
@@ -24,5 +25,9 @@ __all__ = [
|
|||||||
"AlertRecord",
|
"AlertRecord",
|
||||||
"NotificationChannel",
|
"NotificationChannel",
|
||||||
"ModelPricing",
|
"ModelPricing",
|
||||||
"TenantBilling"
|
"TenantBilling",
|
||||||
|
"ScheduledTask",
|
||||||
|
"TaskLog",
|
||||||
|
"ScriptVar",
|
||||||
|
"Secret"
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -18,11 +18,6 @@ class App(Base):
|
|||||||
# [{"code": "brainstorm", "name": "头脑风暴", "path": "/brainstorm"}, ...]
|
# [{"code": "brainstorm", "name": "头脑风暴", "path": "/brainstorm"}, ...]
|
||||||
tools = Column(Text)
|
tools = Column(Text)
|
||||||
|
|
||||||
# 配置项定义(JSON 数组)- 定义租户可配置的参数
|
|
||||||
# [{"key": "industry", "label": "行业类型", "type": "radio", "options": [...], "default": "...", "required": false}, ...]
|
|
||||||
# type: text(文本) | radio(单选) | select(下拉多选) | switch(开关)
|
|
||||||
config_schema = Column(Text)
|
|
||||||
|
|
||||||
# 是否需要企微JS-SDK
|
# 是否需要企微JS-SDK
|
||||||
require_jssdk = Column(SmallInteger, default=0) # 0-不需要 1-需要
|
require_jssdk = Column(SmallInteger, default=0) # 0-不需要 1-需要
|
||||||
|
|
||||||
|
|||||||
96
backend/app/models/scheduled_task.py
Normal file
96
backend/app/models/scheduled_task.py
Normal 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)
|
||||||
@@ -23,10 +23,6 @@ class TenantApp(Base):
|
|||||||
# 功能权限
|
# 功能权限
|
||||||
allowed_tools = Column(Text) # JSON 数组
|
allowed_tools = Column(Text) # JSON 数组
|
||||||
|
|
||||||
# 自定义配置(JSON 数组)
|
|
||||||
# [{"key": "industry", "value": "medical_beauty", "remark": "医美行业"}, ...]
|
|
||||||
custom_configs = Column(Text)
|
|
||||||
|
|
||||||
status = Column(SmallInteger, default=1)
|
status = Column(SmallInteger, default=1)
|
||||||
created_at = Column(TIMESTAMP, default=datetime.now)
|
created_at = Column(TIMESTAMP, default=datetime.now)
|
||||||
updated_at = Column(TIMESTAMP, default=datetime.now, onupdate=datetime.now)
|
updated_at = Column(TIMESTAMP, default=datetime.now, onupdate=datetime.now)
|
||||||
|
|||||||
@@ -23,18 +23,6 @@ class ToolItem(BaseModel):
|
|||||||
path: str
|
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):
|
class AppCreate(BaseModel):
|
||||||
"""创建应用"""
|
"""创建应用"""
|
||||||
app_code: str
|
app_code: str
|
||||||
@@ -42,7 +30,6 @@ class AppCreate(BaseModel):
|
|||||||
base_url: Optional[str] = None
|
base_url: Optional[str] = None
|
||||||
description: Optional[str] = None
|
description: Optional[str] = None
|
||||||
tools: Optional[List[ToolItem]] = None
|
tools: Optional[List[ToolItem]] = None
|
||||||
config_schema: Optional[List[ConfigSchemaItem]] = None
|
|
||||||
require_jssdk: bool = False
|
require_jssdk: bool = False
|
||||||
|
|
||||||
|
|
||||||
@@ -52,7 +39,6 @@ class AppUpdate(BaseModel):
|
|||||||
base_url: Optional[str] = None
|
base_url: Optional[str] = None
|
||||||
description: Optional[str] = None
|
description: Optional[str] = None
|
||||||
tools: Optional[List[ToolItem]] = None
|
tools: Optional[List[ToolItem]] = None
|
||||||
config_schema: Optional[List[ConfigSchemaItem]] = None
|
|
||||||
require_jssdk: Optional[bool] = None
|
require_jssdk: Optional[bool] = None
|
||||||
status: Optional[int] = None
|
status: Optional[int] = None
|
||||||
|
|
||||||
@@ -133,7 +119,6 @@ async def create_app(
|
|||||||
base_url=data.base_url,
|
base_url=data.base_url,
|
||||||
description=data.description,
|
description=data.description,
|
||||||
tools=json.dumps([t.model_dump() for t in data.tools], ensure_ascii=False) if data.tools else None,
|
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,
|
require_jssdk=1 if data.require_jssdk else 0,
|
||||||
status=1
|
status=1
|
||||||
)
|
)
|
||||||
@@ -165,13 +150,6 @@ async def update_app(
|
|||||||
else:
|
else:
|
||||||
update_data['tools'] = None
|
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
|
# 处理 require_jssdk
|
||||||
if 'require_jssdk' in update_data:
|
if 'require_jssdk' in update_data:
|
||||||
update_data['require_jssdk'] = 1 if update_data['require_jssdk'] else 0
|
update_data['require_jssdk'] = 1 if update_data['require_jssdk'] else 0
|
||||||
@@ -281,21 +259,6 @@ async def get_app_tools(
|
|||||||
return 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:
|
def format_app(app: App) -> dict:
|
||||||
"""格式化应用数据"""
|
"""格式化应用数据"""
|
||||||
return {
|
return {
|
||||||
@@ -305,7 +268,6 @@ def format_app(app: App) -> dict:
|
|||||||
"base_url": app.base_url,
|
"base_url": app.base_url,
|
||||||
"description": app.description,
|
"description": app.description,
|
||||||
"tools": json.loads(app.tools) if app.tools else [],
|
"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),
|
"require_jssdk": bool(app.require_jssdk),
|
||||||
"status": app.status,
|
"status": app.status,
|
||||||
"created_at": app.created_at,
|
"created_at": app.created_at,
|
||||||
|
|||||||
@@ -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
@@ -17,13 +17,6 @@ router = APIRouter(prefix="/tenant-apps", tags=["应用配置"])
|
|||||||
|
|
||||||
# Schemas
|
# Schemas
|
||||||
|
|
||||||
class CustomConfigItem(BaseModel):
|
|
||||||
"""自定义配置项"""
|
|
||||||
key: str # 配置键
|
|
||||||
value: str # 配置值
|
|
||||||
remark: Optional[str] = None # 备注说明
|
|
||||||
|
|
||||||
|
|
||||||
class TenantAppCreate(BaseModel):
|
class TenantAppCreate(BaseModel):
|
||||||
tenant_id: str
|
tenant_id: str
|
||||||
app_code: str = "tools"
|
app_code: str = "tools"
|
||||||
@@ -32,7 +25,6 @@ class TenantAppCreate(BaseModel):
|
|||||||
access_token: Optional[str] = None # 如果不传则自动生成
|
access_token: Optional[str] = None # 如果不传则自动生成
|
||||||
allowed_origins: Optional[List[str]] = None
|
allowed_origins: Optional[List[str]] = None
|
||||||
allowed_tools: Optional[List[str]] = None
|
allowed_tools: Optional[List[str]] = None
|
||||||
custom_configs: Optional[List[CustomConfigItem]] = None # 自定义配置
|
|
||||||
|
|
||||||
|
|
||||||
class TenantAppUpdate(BaseModel):
|
class TenantAppUpdate(BaseModel):
|
||||||
@@ -41,7 +33,6 @@ class TenantAppUpdate(BaseModel):
|
|||||||
access_token: Optional[str] = None
|
access_token: Optional[str] = None
|
||||||
allowed_origins: Optional[List[str]] = None
|
allowed_origins: Optional[List[str]] = None
|
||||||
allowed_tools: Optional[List[str]] = None
|
allowed_tools: Optional[List[str]] = None
|
||||||
custom_configs: Optional[List[CustomConfigItem]] = None # 自定义配置
|
|
||||||
status: Optional[int] = None
|
status: Optional[int] = None
|
||||||
|
|
||||||
|
|
||||||
@@ -120,7 +111,6 @@ async def create_tenant_app(
|
|||||||
access_token=access_token,
|
access_token=access_token,
|
||||||
allowed_origins=json.dumps(data.allowed_origins) if data.allowed_origins else None,
|
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,
|
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
|
status=1
|
||||||
)
|
)
|
||||||
db.add(app)
|
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
|
update_data['allowed_origins'] = json.dumps(update_data['allowed_origins']) if update_data['allowed_origins'] else None
|
||||||
if 'allowed_tools' in update_data:
|
if 'allowed_tools' in update_data:
|
||||||
update_data['allowed_tools'] = json.dumps(update_data['allowed_tools']) if update_data['allowed_tools'] else None
|
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():
|
for key, value in update_data.items():
|
||||||
setattr(app, key, value)
|
setattr(app, key, value)
|
||||||
@@ -182,27 +164,6 @@ async def delete_tenant_app(
|
|||||||
return {"success": True}
|
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")
|
@router.post("/{app_id}/regenerate-token")
|
||||||
async def regenerate_token(
|
async def regenerate_token(
|
||||||
app_id: int,
|
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,
|
"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_origins": json.loads(app.allowed_origins) if app.allowed_origins else [],
|
||||||
"allowed_tools": json.loads(app.allowed_tools) if app.allowed_tools 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,
|
"status": app.status,
|
||||||
"created_at": app.created_at,
|
"created_at": app.created_at,
|
||||||
"updated_at": app.updated_at
|
"updated_at": app.updated_at
|
||||||
|
|||||||
@@ -1,399 +1,308 @@
|
|||||||
"""定时任务调度器服务"""
|
"""定时任务调度服务"""
|
||||||
import asyncio
|
import json
|
||||||
import logging
|
|
||||||
from datetime import datetime
|
|
||||||
from typing import Optional, List, Dict, Any
|
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
import asyncio
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional
|
||||||
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
||||||
from apscheduler.triggers.cron import CronTrigger
|
from apscheduler.triggers.cron import CronTrigger
|
||||||
from sqlalchemy import text
|
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from ..database import SessionLocal
|
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
|
scheduler_service = SchedulerService()
|
||||||
|
|
||||||
|
|
||||||
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()
|
|
||||||
|
|||||||
@@ -1,262 +1,246 @@
|
|||||||
"""脚本执行器 - 安全执行 Python 脚本"""
|
"""脚本执行器 - 安全执行Python脚本"""
|
||||||
import asyncio
|
|
||||||
import io
|
|
||||||
import logging
|
|
||||||
import sys
|
import sys
|
||||||
import traceback
|
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 datetime import datetime
|
||||||
from typing import Any, Dict
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from .script_sdk import ScriptSDK
|
from .script_sdk import ScriptSDK
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
# 执行超时时间(秒)
|
|
||||||
SCRIPT_TIMEOUT = 300 # 5 分钟
|
|
||||||
|
|
||||||
# 禁止导入的模块
|
# 禁止导入的模块
|
||||||
FORBIDDEN_MODULES = {
|
FORBIDDEN_MODULES = {
|
||||||
'os', 'subprocess', 'sys', 'builtins', '__builtins__',
|
'os', 'subprocess', 'shutil', 'pathlib',
|
||||||
'importlib', 'eval', 'exec', 'compile',
|
'socket', 'ftplib', 'telnetlib', 'smtplib',
|
||||||
'open', 'file', 'input',
|
'pickle', 'shelve', 'marshal',
|
||||||
'socket', 'multiprocessing', 'threading',
|
'ctypes', 'multiprocessing',
|
||||||
'pickle', 'marshal', 'ctypes',
|
'__builtins__', 'builtins',
|
||||||
'code', 'codeop', 'pty', 'tty',
|
'importlib', 'imp',
|
||||||
|
'code', 'codeop', 'compile',
|
||||||
|
}
|
||||||
|
|
||||||
|
# 允许的内置函数
|
||||||
|
ALLOWED_BUILTINS = {
|
||||||
|
'abs', 'all', 'any', 'ascii', 'bin', 'bool', 'bytearray', 'bytes',
|
||||||
|
'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',
|
||||||
|
'Exception', 'BaseException', 'ValueError', 'TypeError', 'KeyError',
|
||||||
|
'IndexError', 'AttributeError', 'RuntimeError', 'StopIteration',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
class ScriptExecutionResult:
|
class ScriptExecutor:
|
||||||
"""脚本执行结果"""
|
"""脚本执行器"""
|
||||||
|
|
||||||
def __init__(
|
def __init__(self, db: Session):
|
||||||
|
self.db = db
|
||||||
|
|
||||||
|
def execute(
|
||||||
self,
|
self,
|
||||||
success: bool,
|
|
||||||
output: str = "",
|
|
||||||
error: str = None,
|
|
||||||
logs: list = None,
|
|
||||||
execution_time_ms: int = 0
|
|
||||||
):
|
|
||||||
self.success = success
|
|
||||||
self.output = output
|
|
||||||
self.error = error
|
|
||||||
self.logs = logs or []
|
|
||||||
self.execution_time_ms = execution_time_ms
|
|
||||||
|
|
||||||
def to_dict(self) -> Dict[str, Any]:
|
|
||||||
return {
|
|
||||||
"success": self.success,
|
|
||||||
"output": self.output,
|
|
||||||
"error": self.error,
|
|
||||||
"logs": self.logs,
|
|
||||||
"execution_time_ms": self.execution_time_ms
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def create_safe_builtins() -> Dict[str, Any]:
|
|
||||||
"""创建安全的内置函数集"""
|
|
||||||
import builtins
|
|
||||||
|
|
||||||
# 允许的内置函数
|
|
||||||
allowed = [
|
|
||||||
'abs', 'all', 'any', 'ascii', 'bin', 'bool', 'bytearray', 'bytes',
|
|
||||||
'callable', 'chr', 'complex', 'dict', 'divmod', 'enumerate',
|
|
||||||
'filter', 'float', 'format', 'frozenset', 'getattr', 'hasattr',
|
|
||||||
'hash', 'hex', 'id', 'int', 'isinstance', 'issubclass', 'iter',
|
|
||||||
'len', 'list', 'map', 'max', 'min', 'next', 'object', 'oct',
|
|
||||||
'ord', 'pow', 'print', 'range', 'repr', 'reversed', 'round',
|
|
||||||
'set', 'slice', 'sorted', 'str', 'sum', 'tuple', 'type', 'zip',
|
|
||||||
'True', 'False', 'None',
|
|
||||||
]
|
|
||||||
|
|
||||||
safe_builtins = {}
|
|
||||||
for name in allowed:
|
|
||||||
if hasattr(builtins, name):
|
|
||||||
safe_builtins[name] = getattr(builtins, name)
|
|
||||||
|
|
||||||
# 添加安全的 import 函数
|
|
||||||
def safe_import(name, *args, **kwargs):
|
|
||||||
"""安全的 import 函数,只允许特定模块"""
|
|
||||||
allowed_modules = {
|
|
||||||
'json', 'datetime', 'time', 're', 'math', 'random',
|
|
||||||
'collections', 'itertools', 'functools', 'operator',
|
|
||||||
'string', 'textwrap', 'unicodedata',
|
|
||||||
'hashlib', 'base64', 'urllib.parse',
|
|
||||||
}
|
|
||||||
|
|
||||||
if name in FORBIDDEN_MODULES:
|
|
||||||
raise ImportError(f"禁止导入模块: {name}")
|
|
||||||
|
|
||||||
if name not in allowed_modules and not name.startswith('urllib.parse'):
|
|
||||||
raise ImportError(f"不允许导入模块: {name},允许的模块: {', '.join(sorted(allowed_modules))}")
|
|
||||||
|
|
||||||
return __builtins__['__import__'](name, *args, **kwargs)
|
|
||||||
|
|
||||||
safe_builtins['__import__'] = safe_import
|
|
||||||
|
|
||||||
return safe_builtins
|
|
||||||
|
|
||||||
|
|
||||||
async def execute_script(
|
|
||||||
task_id: int,
|
|
||||||
tenant_id: str,
|
|
||||||
script_content: str,
|
script_content: str,
|
||||||
trace_id: str = None
|
task_id: int,
|
||||||
) -> ScriptExecutionResult:
|
tenant_id: Optional[str] = None,
|
||||||
"""
|
trace_id: Optional[str] = None,
|
||||||
执行 Python 脚本
|
params: Optional[Dict[str, Any]] = None,
|
||||||
|
timeout: int = 300
|
||||||
|
) -> Tuple[bool, str, str]:
|
||||||
|
"""执行脚本
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
task_id: 任务 ID
|
script_content: Python脚本内容
|
||||||
tenant_id: 租户 ID
|
task_id: 任务ID
|
||||||
script_content: 脚本内容
|
tenant_id: 租户ID
|
||||||
trace_id: 追踪 ID
|
trace_id: 追踪ID
|
||||||
|
params: 输入参数
|
||||||
|
timeout: 超时秒数
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
ScriptExecutionResult: 执行结果
|
(success, output, error)
|
||||||
"""
|
"""
|
||||||
start_time = datetime.now()
|
# 创建SDK实例
|
||||||
sdk = None
|
sdk = ScriptSDK(
|
||||||
|
db=self.db,
|
||||||
|
task_id=task_id,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
trace_id=trace_id,
|
||||||
|
params=params or {}
|
||||||
|
)
|
||||||
|
|
||||||
try:
|
# 检查脚本安全性
|
||||||
# 创建 SDK 实例
|
check_result = self._check_script_safety(script_content)
|
||||||
sdk = ScriptSDK(tenant_id, task_id, trace_id)
|
if check_result:
|
||||||
|
return False, '', f"脚本安全检查失败: {check_result}"
|
||||||
|
|
||||||
# 准备执行环境
|
# 准备执行环境
|
||||||
script_globals = {
|
safe_globals = self._create_safe_globals(sdk)
|
||||||
'__builtins__': create_safe_builtins(),
|
|
||||||
'__name__': '__script__',
|
|
||||||
|
|
||||||
# SDK 实例
|
|
||||||
'sdk': sdk,
|
|
||||||
|
|
||||||
# 快捷方法(同步包装)
|
|
||||||
'ai': lambda *args, **kwargs: asyncio.get_event_loop().run_until_complete(sdk.ai_chat(*args, **kwargs)),
|
|
||||||
'dingtalk': lambda *args, **kwargs: asyncio.get_event_loop().run_until_complete(sdk.send_dingtalk(*args, **kwargs)),
|
|
||||||
'wecom': lambda *args, **kwargs: asyncio.get_event_loop().run_until_complete(sdk.send_wecom(*args, **kwargs)),
|
|
||||||
'http_get': lambda *args, **kwargs: asyncio.get_event_loop().run_until_complete(sdk.http_get(*args, **kwargs)),
|
|
||||||
'http_post': lambda *args, **kwargs: asyncio.get_event_loop().run_until_complete(sdk.http_post(*args, **kwargs)),
|
|
||||||
|
|
||||||
# 同步方法
|
|
||||||
'db': sdk.db_query,
|
|
||||||
'get_var': sdk.get_var,
|
|
||||||
'set_var': sdk.set_var,
|
|
||||||
'delete_var': sdk.delete_var,
|
|
||||||
'log': sdk.log,
|
|
||||||
|
|
||||||
# 常用模块
|
|
||||||
'json': __import__('json'),
|
|
||||||
'datetime': __import__('datetime'),
|
|
||||||
're': __import__('re'),
|
|
||||||
'math': __import__('math'),
|
|
||||||
'random': __import__('random'),
|
|
||||||
}
|
|
||||||
|
|
||||||
# 捕获输出
|
# 捕获输出
|
||||||
stdout = io.StringIO()
|
old_stdout = sys.stdout
|
||||||
stderr = io.StringIO()
|
old_stderr = sys.stderr
|
||||||
|
stdout_capture = StringIO()
|
||||||
|
stderr_capture = StringIO()
|
||||||
|
|
||||||
sdk.log("脚本开始执行")
|
try:
|
||||||
|
sys.stdout = stdout_capture
|
||||||
|
sys.stderr = stderr_capture
|
||||||
|
|
||||||
# 编译并执行脚本
|
# 编译并执行脚本
|
||||||
try:
|
compiled = compile(script_content, '<script>', 'exec')
|
||||||
# 编译脚本
|
exec(compiled, safe_globals)
|
||||||
code = compile(script_content, '<script>', 'exec')
|
|
||||||
|
|
||||||
# 执行(带超时)
|
# 获取输出
|
||||||
async def run_script():
|
stdout_output = stdout_capture.getvalue()
|
||||||
with redirect_stdout(stdout), redirect_stderr(stderr):
|
sdk_output = sdk.get_output()
|
||||||
exec(code, script_globals)
|
|
||||||
|
|
||||||
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)
|
return True, output, ''
|
||||||
sdk.log(f"脚本执行完成,耗时 {execution_time}ms")
|
|
||||||
|
|
||||||
return ScriptExecutionResult(
|
|
||||||
success=True,
|
|
||||||
output=stdout.getvalue(),
|
|
||||||
logs=sdk.get_logs(),
|
|
||||||
execution_time_ms=execution_time
|
|
||||||
)
|
|
||||||
|
|
||||||
except asyncio.TimeoutError:
|
|
||||||
execution_time = int((datetime.now() - start_time).total_seconds() * 1000)
|
|
||||||
error_msg = f"脚本执行超时(超过 {SCRIPT_TIMEOUT} 秒)"
|
|
||||||
sdk.log(error_msg, level="ERROR")
|
|
||||||
|
|
||||||
return ScriptExecutionResult(
|
|
||||||
success=False,
|
|
||||||
output=stdout.getvalue(),
|
|
||||||
error=error_msg,
|
|
||||||
logs=sdk.get_logs(),
|
|
||||||
execution_time_ms=execution_time
|
|
||||||
)
|
|
||||||
|
|
||||||
except SyntaxError as e:
|
|
||||||
execution_time = int((datetime.now() - start_time).total_seconds() * 1000)
|
|
||||||
error_msg = f"语法错误: 第 {e.lineno} 行 - {e.msg}"
|
|
||||||
sdk.log(error_msg, level="ERROR")
|
|
||||||
|
|
||||||
return ScriptExecutionResult(
|
|
||||||
success=False,
|
|
||||||
output=stdout.getvalue(),
|
|
||||||
error=error_msg,
|
|
||||||
logs=sdk.get_logs(),
|
|
||||||
execution_time_ms=execution_time
|
|
||||||
)
|
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
execution_time = int((datetime.now() - start_time).total_seconds() * 1000)
|
error_msg = f"{type(e).__name__}: {str(e)}\n{traceback.format_exc()}"
|
||||||
error_msg = f"{type(e).__name__}: {str(e)}"
|
return False, sdk.get_output(), error_msg
|
||||||
|
|
||||||
# 获取详细的错误堆栈
|
|
||||||
tb = traceback.format_exc()
|
|
||||||
sdk.log(f"执行错误: {error_msg}\n{tb}", level="ERROR")
|
|
||||||
|
|
||||||
return ScriptExecutionResult(
|
|
||||||
success=False,
|
|
||||||
output=stdout.getvalue(),
|
|
||||||
error=error_msg,
|
|
||||||
logs=sdk.get_logs(),
|
|
||||||
execution_time_ms=execution_time
|
|
||||||
)
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
execution_time = int((datetime.now() - start_time).total_seconds() * 1000)
|
|
||||||
error_msg = f"执行器错误: {str(e)}"
|
|
||||||
logger.error(f"Script executor error: {e}", exc_info=True)
|
|
||||||
|
|
||||||
return ScriptExecutionResult(
|
|
||||||
success=False,
|
|
||||||
error=error_msg,
|
|
||||||
logs=sdk.get_logs() if sdk else [],
|
|
||||||
execution_time_ms=execution_time
|
|
||||||
)
|
|
||||||
|
|
||||||
finally:
|
finally:
|
||||||
# 清理资源
|
sys.stdout = old_stdout
|
||||||
if sdk:
|
sys.stderr = old_stderr
|
||||||
sdk.cleanup()
|
|
||||||
|
|
||||||
|
def _check_script_safety(self, script_content: str) -> Optional[str]:
|
||||||
async def test_script(
|
"""检查脚本安全性
|
||||||
tenant_id: str,
|
|
||||||
script_content: str
|
|
||||||
) -> ScriptExecutionResult:
|
|
||||||
"""
|
|
||||||
测试执行脚本(不记录日志到数据库)
|
|
||||||
|
|
||||||
Args:
|
|
||||||
tenant_id: 租户 ID
|
|
||||||
script_content: 脚本内容
|
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
ScriptExecutionResult: 执行结果
|
错误消息,如果安全则返回None
|
||||||
"""
|
"""
|
||||||
return await execute_script(
|
# 检查危险导入
|
||||||
task_id=0, # 测试用 ID
|
import_patterns = [
|
||||||
tenant_id=tenant_id,
|
'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,
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,79 +1,113 @@
|
|||||||
"""脚本执行 SDK - 提供给 Python 脚本使用的内置能力"""
|
"""脚本执行SDK - 为Python脚本提供内置功能"""
|
||||||
import json
|
import json
|
||||||
import logging
|
import os
|
||||||
|
import httpx
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Any, Dict, List, Optional
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
import httpx
|
|
||||||
from sqlalchemy import text
|
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from ..database import SessionLocal
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
class ScriptSDK:
|
class ScriptSDK:
|
||||||
"""
|
"""脚本SDK - 提供AI、通知、数据库、HTTP、变量存储等功能"""
|
||||||
脚本执行 SDK
|
|
||||||
|
|
||||||
提供以下能力:
|
def __init__(
|
||||||
- AI 大模型调用
|
self,
|
||||||
- 钉钉/企微通知
|
db: Session,
|
||||||
- 数据库查询(只读)
|
task_id: int,
|
||||||
- HTTP 请求
|
tenant_id: Optional[str] = None,
|
||||||
- 变量存储(跨执行持久化)
|
trace_id: Optional[str] = None,
|
||||||
- 日志记录
|
params: Optional[Dict[str, Any]] = None
|
||||||
- 多租户遍历
|
):
|
||||||
- 密钥管理
|
self.db = db
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, tenant_id: str, task_id: int, trace_id: str = None):
|
|
||||||
self.tenant_id = tenant_id
|
|
||||||
self.task_id = task_id
|
self.task_id = task_id
|
||||||
self.trace_id = trace_id or f"script_{task_id}_{datetime.now().strftime('%Y%m%d%H%M%S')}"
|
self.tenant_id = tenant_id
|
||||||
self._logs: List[str] = []
|
self.trace_id = trace_id
|
||||||
self._db: Optional[Session] = None
|
self.params = params or {}
|
||||||
self._tenants_cache: Optional[List[Dict]] = None # 租户列表缓存
|
|
||||||
|
|
||||||
def _get_db(self) -> Session:
|
self._logs: List[Dict] = []
|
||||||
"""获取数据库会话"""
|
self._output: List[str] = []
|
||||||
if self._db is None:
|
self._tenants_cache: Dict = {}
|
||||||
self._db = SessionLocal()
|
|
||||||
return self._db
|
|
||||||
|
|
||||||
def _close_db(self):
|
# AI 配置
|
||||||
"""关闭数据库会话"""
|
self._ai_base_url = os.getenv('OPENAI_BASE_URL', 'https://api.4sapi.net/v1')
|
||||||
if self._db:
|
self._ai_api_key = os.getenv('OPENAI_API_KEY', 'sk-9yMCXjRGANbacz20kJY8doSNy6Rf446aYwmgGIuIXQ7DAyBw')
|
||||||
self._db.close()
|
self._ai_model = os.getenv('OPENAI_MODEL', 'gemini-2.5-flash')
|
||||||
self._db = None
|
|
||||||
|
|
||||||
# ============ 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,
|
self,
|
||||||
prompt: str,
|
prompt: str,
|
||||||
system: str = None,
|
system: Optional[str] = None,
|
||||||
model: str = "gemini-2.5-flash",
|
model: Optional[str] = None,
|
||||||
temperature: float = 0.7,
|
temperature: float = 0.7,
|
||||||
max_tokens: int = 2000
|
max_tokens: int = 2000
|
||||||
) -> str:
|
) -> str:
|
||||||
"""
|
"""调用AI模型
|
||||||
调用大模型
|
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
prompt: 用户提示词
|
prompt: 用户提示词
|
||||||
system: 系统提示词(可选)
|
system: 系统提示词
|
||||||
model: 模型名称,默认 gemini-2.5-flash
|
model: 模型名称(默认gemini-2.5-flash)
|
||||||
temperature: 温度,默认 0.7
|
temperature: 温度参数
|
||||||
max_tokens: 最大 token 数,默认 2000
|
max_tokens: 最大token数
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
AI 生成的文本
|
AI响应内容
|
||||||
"""
|
"""
|
||||||
# 使用 4sapi 作为 AI 服务
|
model = model or self._ai_model
|
||||||
api_key = "sk-9yMCXjRGANbacz20kJY8doSNy6Rf446aYwmgGIuIXQ7DAyBw"
|
|
||||||
base_url = "https://4sapi.com/v1"
|
|
||||||
|
|
||||||
messages = []
|
messages = []
|
||||||
if system:
|
if system:
|
||||||
@@ -81,11 +115,11 @@ class ScriptSDK:
|
|||||||
messages.append({"role": "user", "content": prompt})
|
messages.append({"role": "user", "content": prompt})
|
||||||
|
|
||||||
try:
|
try:
|
||||||
async with httpx.AsyncClient(timeout=120.0) as client:
|
with httpx.Client(timeout=60) as client:
|
||||||
response = await client.post(
|
response = client.post(
|
||||||
f"{base_url}/chat/completions",
|
f"{self._ai_base_url}/chat/completions",
|
||||||
headers={
|
headers={
|
||||||
"Authorization": f"Bearer {api_key}",
|
"Authorization": f"Bearer {self._ai_api_key}",
|
||||||
"Content-Type": "application/json"
|
"Content-Type": "application/json"
|
||||||
},
|
},
|
||||||
json={
|
json={
|
||||||
@@ -97,515 +131,349 @@ class ScriptSDK:
|
|||||||
)
|
)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
data = response.json()
|
data = response.json()
|
||||||
content = data["choices"][0]["message"]["content"]
|
content = data['choices'][0]['message']['content']
|
||||||
self.log(f"AI 调用成功,模型: {model},响应长度: {len(content)}")
|
self.log(f"AI调用成功: {len(content)} 字符")
|
||||||
return content
|
return content
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.log(f"AI 调用失败: {str(e)}", level="ERROR")
|
self.log(f"AI调用失败: {str(e)}", 'ERROR')
|
||||||
raise
|
raise
|
||||||
|
|
||||||
# ============ 通知服务 ============
|
# ==================== 通知 ====================
|
||||||
|
|
||||||
async def send_dingtalk(
|
def dingtalk(self, webhook: str, content: str, title: Optional[str] = None, at_all: bool = False) -> bool:
|
||||||
self,
|
"""发送钉钉消息
|
||||||
webhook: str,
|
|
||||||
content: str,
|
|
||||||
msg_type: str = "text",
|
|
||||||
at_mobiles: List[str] = None,
|
|
||||||
at_all: bool = False
|
|
||||||
) -> bool:
|
|
||||||
"""
|
|
||||||
发送钉钉群消息
|
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
webhook: 钉钉机器人 Webhook URL
|
webhook: 钉钉机器人webhook地址
|
||||||
content: 消息内容
|
content: 消息内容(支持Markdown)
|
||||||
msg_type: 消息类型,text 或 markdown
|
title: 消息标题
|
||||||
at_mobiles: @的手机号列表
|
|
||||||
at_all: 是否@所有人
|
at_all: 是否@所有人
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
是否发送成功
|
是否发送成功
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
if msg_type == "text":
|
payload = {
|
||||||
data = {
|
|
||||||
"msgtype": "text",
|
|
||||||
"text": {"content": content},
|
|
||||||
"at": {
|
|
||||||
"atMobiles": at_mobiles or [],
|
|
||||||
"isAtAll": at_all
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else:
|
|
||||||
data = {
|
|
||||||
"msgtype": "markdown",
|
"msgtype": "markdown",
|
||||||
"markdown": {
|
"markdown": {
|
||||||
"title": content[:20] if len(content) > 20 else content,
|
"title": title or "通知",
|
||||||
"text": content
|
"text": content + ("\n@所有人" if at_all else "")
|
||||||
},
|
},
|
||||||
"at": {
|
"at": {"isAtAll": at_all}
|
||||||
"atMobiles": at_mobiles or [],
|
|
||||||
"isAtAll": at_all
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
with httpx.Client(timeout=10) as client:
|
||||||
response = await client.post(webhook, json=data)
|
response = client.post(webhook, json=payload)
|
||||||
|
response.raise_for_status()
|
||||||
result = response.json()
|
result = response.json()
|
||||||
|
success = result.get('errcode') == 0
|
||||||
if result.get("errcode") == 0:
|
self.log(f"钉钉消息发送{'成功' if success else '失败'}")
|
||||||
self.log(f"钉钉消息发送成功")
|
return success
|
||||||
return True
|
|
||||||
else:
|
|
||||||
self.log(f"钉钉消息发送失败: {result.get('errmsg')}", level="ERROR")
|
|
||||||
return False
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.log(f"钉钉消息发送异常: {str(e)}", level="ERROR")
|
self.log(f"钉钉消息发送失败: {str(e)}", 'ERROR')
|
||||||
return False
|
return False
|
||||||
|
|
||||||
async def send_wecom(
|
def wecom(self, webhook: str, content: str, msg_type: str = 'markdown') -> bool:
|
||||||
self,
|
"""发送企业微信消息
|
||||||
webhook: str,
|
|
||||||
content: str,
|
|
||||||
msg_type: str = "text"
|
|
||||||
) -> bool:
|
|
||||||
"""
|
|
||||||
发送企业微信群消息
|
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
webhook: 企微机器人 Webhook URL
|
webhook: 企微机器人webhook地址
|
||||||
content: 消息内容
|
content: 消息内容
|
||||||
msg_type: 消息类型,text 或 markdown
|
msg_type: 消息类型 (text, markdown)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
是否发送成功
|
是否发送成功
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
if msg_type == "text":
|
if msg_type == 'markdown':
|
||||||
data = {
|
payload = {
|
||||||
"msgtype": "text",
|
|
||||||
"text": {"content": content}
|
|
||||||
}
|
|
||||||
else:
|
|
||||||
data = {
|
|
||||||
"msgtype": "markdown",
|
"msgtype": "markdown",
|
||||||
"markdown": {"content": content}
|
"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:
|
else:
|
||||||
self.log(f"企微消息发送失败: {result.get('errmsg')}", level="ERROR")
|
payload = {
|
||||||
return False
|
"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:
|
except Exception as e:
|
||||||
self.log(f"企微消息发送异常: {str(e)}", level="ERROR")
|
self.log(f"企微消息发送失败: {str(e)}", 'ERROR')
|
||||||
return False
|
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:
|
Args:
|
||||||
sql: SQL 语句(仅支持 SELECT)
|
sql: SQL语句(必须是SELECT)
|
||||||
params: 查询参数
|
params: 参数字典
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
查询结果列表
|
查询结果列表
|
||||||
"""
|
"""
|
||||||
# 安全检查:只允许 SELECT
|
|
||||||
sql_upper = sql.strip().upper()
|
sql_upper = sql.strip().upper()
|
||||||
if not sql_upper.startswith("SELECT"):
|
if not sql_upper.startswith('SELECT'):
|
||||||
raise ValueError("只允许 SELECT 查询")
|
raise ValueError("只允许执行SELECT查询")
|
||||||
|
|
||||||
forbidden = ["INSERT", "UPDATE", "DELETE", "DROP", "CREATE", "ALTER", "TRUNCATE"]
|
# 禁止危险操作
|
||||||
|
forbidden = ['INSERT', 'UPDATE', 'DELETE', 'DROP', 'TRUNCATE', 'ALTER', 'CREATE']
|
||||||
for word in forbidden:
|
for word in forbidden:
|
||||||
if word in sql_upper:
|
if word in sql_upper:
|
||||||
raise ValueError(f"禁止使用 {word} 语句")
|
raise ValueError(f"禁止执行 {word} 操作")
|
||||||
|
|
||||||
db = self._get_db()
|
|
||||||
try:
|
try:
|
||||||
result = db.execute(text(sql), params or {})
|
from sqlalchemy import text
|
||||||
rows = [dict(row) for row in result.mappings().all()]
|
result = self.db.execute(text(sql), params or {})
|
||||||
self.log(f"SQL 查询成功,返回 {len(rows)} 条记录")
|
columns = result.keys()
|
||||||
|
rows = [dict(zip(columns, row)) for row in result.fetchall()]
|
||||||
|
self.log(f"SQL查询返回 {len(rows)} 条记录")
|
||||||
return rows
|
return rows
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.log(f"SQL 查询失败: {str(e)}", level="ERROR")
|
self.log(f"SQL查询失败: {str(e)}", 'ERROR')
|
||||||
raise
|
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:
|
def get_var(self, key: str, default: Any = None) -> Any:
|
||||||
"""
|
"""获取持久化变量
|
||||||
获取存储的变量
|
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
key: 变量名
|
key: 变量名
|
||||||
default: 默认值
|
default: 默认值
|
||||||
|
|
||||||
Returns:
|
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:
|
try:
|
||||||
result = db.execute(
|
return json.loads(var.var_value)
|
||||||
text("SELECT var_value FROM platform_script_vars WHERE tenant_id = :tid AND var_key = :key"),
|
|
||||||
{"tid": self.tenant_id, "key": key}
|
|
||||||
)
|
|
||||||
row = result.first()
|
|
||||||
if row:
|
|
||||||
try:
|
|
||||||
return json.loads(row[0])
|
|
||||||
except:
|
except:
|
||||||
return row[0]
|
return var.var_value
|
||||||
return default
|
|
||||||
except Exception as e:
|
|
||||||
self.log(f"获取变量失败: {str(e)}", level="ERROR")
|
|
||||||
return default
|
return default
|
||||||
|
|
||||||
def set_var(self, key: str, value: Any) -> bool:
|
def set_var(self, key: str, value: Any) -> None:
|
||||||
"""
|
"""设置持久化变量
|
||||||
存储变量(跨执行持久化)
|
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
key: 变量名
|
key: 变量名
|
||||||
value: 变量值(会 JSON 序列化)
|
value: 变量值(会JSON序列化)
|
||||||
|
|
||||||
Returns:
|
|
||||||
是否成功
|
|
||||||
"""
|
"""
|
||||||
db = self._get_db()
|
from ..models.scheduled_task import ScriptVar
|
||||||
try:
|
|
||||||
value_str = json.dumps(value, ensure_ascii=False) if not isinstance(value, str) else value
|
|
||||||
|
|
||||||
# 使用 REPLACE INTO 实现 upsert
|
var = self.db.query(ScriptVar).filter(
|
||||||
db.execute(
|
ScriptVar.task_id == self.task_id,
|
||||||
text("""
|
ScriptVar.tenant_id == self.tenant_id,
|
||||||
REPLACE INTO platform_script_vars (tenant_id, var_key, var_value, updated_at)
|
ScriptVar.var_key == key
|
||||||
VALUES (:tid, :key, :value, NOW())
|
).first()
|
||||||
"""),
|
|
||||||
{"tid": self.tenant_id, "key": key, "value": value_str}
|
|
||||||
)
|
|
||||||
db.commit()
|
|
||||||
self.log(f"变量已存储: {key}")
|
|
||||||
return True
|
|
||||||
except Exception as e:
|
|
||||||
self.log(f"存储变量失败: {str(e)}", level="ERROR")
|
|
||||||
return False
|
|
||||||
|
|
||||||
def delete_var(self, key: str) -> bool:
|
value_json = json.dumps(value, ensure_ascii=False)
|
||||||
"""
|
|
||||||
删除变量
|
|
||||||
|
|
||||||
Args:
|
if var:
|
||||||
key: 变量名
|
var.var_value = value_json
|
||||||
|
|
||||||
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}
|
|
||||||
)
|
|
||||||
else:
|
else:
|
||||||
# 获取所有启用的租户
|
var = ScriptVar(
|
||||||
result = db.execute(
|
task_id=self.task_id,
|
||||||
text("SELECT code, name FROM platform_tenants WHERE status = 1")
|
tenant_id=self.tenant_id,
|
||||||
|
var_key=key,
|
||||||
|
var_value=value_json
|
||||||
)
|
)
|
||||||
|
self.db.add(var)
|
||||||
|
|
||||||
tenants = []
|
self.db.commit()
|
||||||
for row in result.mappings().all():
|
self.log(f"变量 {key} 已保存")
|
||||||
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.log(f"获取租户列表成功,共 {len(tenants)} 个租户")
|
def del_var(self, key: str) -> bool:
|
||||||
return tenants
|
"""删除持久化变量"""
|
||||||
except Exception as e:
|
from ..models.scheduled_task import ScriptVar
|
||||||
self.log(f"获取租户列表失败: {str(e)}", level="ERROR")
|
|
||||||
return []
|
|
||||||
|
|
||||||
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:
|
Args:
|
||||||
tenant_id: 租户ID
|
tenant_id: 租户ID
|
||||||
app_code: 应用代码
|
app_code: 应用代码
|
||||||
key: 配置项键名(可选,不传返回全部配置)
|
key: 配置键(可选,不提供则返回所有配置)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
配置值或配置字典
|
配置值或配置字典
|
||||||
"""
|
"""
|
||||||
db = self._get_db()
|
from ..models.tenant_app import TenantApp
|
||||||
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()
|
|
||||||
|
|
||||||
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 {}
|
return None if key else {}
|
||||||
|
|
||||||
try:
|
# 解析 custom_configs
|
||||||
configs = json.loads(row[0])
|
|
||||||
except:
|
|
||||||
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:
|
if key:
|
||||||
return configs.get(key)
|
return configs.get(key)
|
||||||
return configs
|
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]:
|
def get_all_tenant_configs(self, app_code: str) -> List[Dict]:
|
||||||
"""
|
"""获取所有租户的应用配置
|
||||||
获取所有租户的应用配置(便捷方法,用于批量操作)
|
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
app_code: 应用代码
|
app_code: 应用代码
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
[{"tenant_id": "xxx", "tenant_name": "租户名", "configs": {...}}]
|
[{"tenant_id": ..., "tenant_name": ..., "configs": {...}}, ...]
|
||||||
"""
|
"""
|
||||||
db = self._get_db()
|
from ..models.tenant import Tenant
|
||||||
try:
|
from ..models.tenant_app import TenantApp
|
||||||
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}
|
|
||||||
)
|
|
||||||
|
|
||||||
tenants = []
|
tenant_apps = self.db.query(TenantApp).filter(
|
||||||
for row in result.mappings().all():
|
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 = {}
|
configs = {}
|
||||||
if row["custom_configs"]:
|
if hasattr(ta, 'custom_configs') and ta.custom_configs:
|
||||||
try:
|
try:
|
||||||
configs = json.loads(row["custom_configs"])
|
configs = json.loads(ta.custom_configs) if isinstance(ta.custom_configs, str) else ta.custom_configs
|
||||||
except:
|
except:
|
||||||
pass
|
pass
|
||||||
tenants.append({
|
|
||||||
"tenant_id": row["tenant_id"],
|
result.append({
|
||||||
"tenant_name": row["tenant_name"],
|
"tenant_id": ta.tenant_id,
|
||||||
|
"tenant_name": tenant.name if tenant else ta.tenant_id,
|
||||||
"configs": configs
|
"configs": configs
|
||||||
})
|
})
|
||||||
|
|
||||||
self.log(f"获取 {app_code} 应用的租户配置,共 {len(tenants)} 个")
|
return result
|
||||||
return tenants
|
|
||||||
except Exception as e:
|
|
||||||
self.log(f"获取租户配置失败: {str(e)}", level="ERROR")
|
|
||||||
return []
|
|
||||||
|
|
||||||
# ============ 密钥管理 ============
|
# ==================== 密钥管理 ====================
|
||||||
|
|
||||||
def get_secret(self, key: str) -> Optional[str]:
|
def get_secret(self, key: str) -> Optional[str]:
|
||||||
"""
|
"""获取密钥(优先租户级,其次全局)
|
||||||
获取密钥(优先读取租户级密钥,其次读取全局密钥)
|
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
key: 密钥名称
|
key: 密钥名
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
密钥值(如不存在返回 None)
|
密钥值
|
||||||
"""
|
"""
|
||||||
db = self._get_db()
|
from ..models.scheduled_task import Secret
|
||||||
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()
|
|
||||||
|
|
||||||
if row:
|
# 先查租户级
|
||||||
self.log(f"获取密钥成功: {key}")
|
if self.tenant_id:
|
||||||
return row[0]
|
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
|
secret = self.db.query(Secret).filter(
|
||||||
except Exception as e:
|
Secret.tenant_id.is_(None),
|
||||||
self.log(f"获取密钥失败: {str(e)}", level="ERROR")
|
Secret.secret_key == key
|
||||||
return None
|
).first()
|
||||||
|
|
||||||
# ============ 日志 ============
|
return secret.secret_value if secret else None
|
||||||
|
|
||||||
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()
|
|
||||||
|
|||||||
@@ -16,10 +16,9 @@ const menuItems = computed(() => {
|
|||||||
{ path: '/apps', title: '应用管理', icon: 'Grid' },
|
{ path: '/apps', title: '应用管理', icon: 'Grid' },
|
||||||
{ path: '/tenant-wechat-apps', title: '企微应用', icon: 'ChatDotRound' },
|
{ path: '/tenant-wechat-apps', title: '企微应用', icon: 'ChatDotRound' },
|
||||||
{ path: '/app-config', title: '租户订阅', icon: 'Setting' },
|
{ path: '/app-config', title: '租户订阅', icon: 'Setting' },
|
||||||
{ path: '/scheduled-tasks', title: '定时任务', icon: 'Clock' },
|
|
||||||
{ path: '/scripts', title: '脚本管理', icon: 'Tickets' },
|
|
||||||
{ path: '/stats', title: '统计分析', icon: 'TrendCharts' },
|
{ path: '/stats', title: '统计分析', icon: 'TrendCharts' },
|
||||||
{ path: '/logs', title: '日志查看', icon: 'Document' }
|
{ path: '/logs', title: '日志查看', icon: 'Document' },
|
||||||
|
{ path: '/scheduled-tasks', title: '定时任务', icon: 'Clock' }
|
||||||
]
|
]
|
||||||
|
|
||||||
// 管理员才能看到用户管理
|
// 管理员才能看到用户管理
|
||||||
|
|||||||
@@ -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>
|
|
||||||
@@ -53,19 +53,7 @@ const routes = [
|
|||||||
path: 'app-config',
|
path: 'app-config',
|
||||||
name: 'AppConfig',
|
name: 'AppConfig',
|
||||||
component: () => import('@/views/app-config/index.vue'),
|
component: () => import('@/views/app-config/index.vue'),
|
||||||
meta: { title: '租户订阅', icon: 'Setting' }
|
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' }
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'stats',
|
path: 'stats',
|
||||||
@@ -84,6 +72,12 @@ const routes = [
|
|||||||
name: 'Users',
|
name: 'Users',
|
||||||
component: () => import('@/views/users/index.vue'),
|
component: () => import('@/views/users/index.vue'),
|
||||||
meta: { title: '用户管理', icon: 'User', role: 'admin' }
|
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
@@ -1,7 +1,6 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, reactive, onMounted } from 'vue'
|
import { ref, reactive, onMounted } from 'vue'
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
import { Delete, Plus } from '@element-plus/icons-vue'
|
|
||||||
import api from '@/api'
|
import api from '@/api'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
|
||||||
@@ -15,14 +14,6 @@ const query = reactive({
|
|||||||
size: 20
|
size: 20
|
||||||
})
|
})
|
||||||
|
|
||||||
// 配置项类型选项
|
|
||||||
const configTypes = [
|
|
||||||
{ value: 'text', label: '文本输入' },
|
|
||||||
{ value: 'radio', label: '单选' },
|
|
||||||
{ value: 'select', label: '下拉选择' },
|
|
||||||
{ value: 'switch', label: '开关' }
|
|
||||||
]
|
|
||||||
|
|
||||||
// 对话框
|
// 对话框
|
||||||
const dialogVisible = ref(false)
|
const dialogVisible = ref(false)
|
||||||
const dialogTitle = ref('')
|
const dialogTitle = ref('')
|
||||||
@@ -33,8 +24,7 @@ const form = reactive({
|
|||||||
app_name: '',
|
app_name: '',
|
||||||
base_url: '',
|
base_url: '',
|
||||||
description: '',
|
description: '',
|
||||||
require_jssdk: false,
|
require_jssdk: false
|
||||||
config_schema: [] // 配置项定义
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const rules = {
|
const rules = {
|
||||||
@@ -73,8 +63,7 @@ function handleCreate() {
|
|||||||
app_name: '',
|
app_name: '',
|
||||||
base_url: '',
|
base_url: '',
|
||||||
description: '',
|
description: '',
|
||||||
require_jssdk: false,
|
require_jssdk: false
|
||||||
config_schema: []
|
|
||||||
})
|
})
|
||||||
dialogVisible.value = true
|
dialogVisible.value = true
|
||||||
}
|
}
|
||||||
@@ -87,55 +76,11 @@ function handleEdit(row) {
|
|||||||
app_name: row.app_name,
|
app_name: row.app_name,
|
||||||
base_url: row.base_url || '',
|
base_url: row.base_url || '',
|
||||||
description: row.description || '',
|
description: row.description || '',
|
||||||
require_jssdk: row.require_jssdk || false,
|
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 || {}
|
|
||||||
})) : []
|
|
||||||
})
|
})
|
||||||
dialogVisible.value = true
|
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() {
|
async function handleSubmit() {
|
||||||
await formRef.value.validate()
|
await formRef.value.validate()
|
||||||
|
|
||||||
@@ -209,14 +154,6 @@ onMounted(() => {
|
|||||||
<el-table-column prop="app_code" label="应用代码" width="150" />
|
<el-table-column prop="app_code" label="应用代码" width="150" />
|
||||||
<el-table-column prop="app_name" label="应用名称" width="180" />
|
<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 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">
|
<el-table-column label="JS-SDK" width="90">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<el-tag :type="row.require_jssdk ? 'warning' : 'info'" size="small">
|
<el-tag :type="row.require_jssdk ? 'warning' : 'info'" size="small">
|
||||||
@@ -254,7 +191,7 @@ onMounted(() => {
|
|||||||
</div>
|
</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 ref="formRef" :model="form" :rules="rules" label-width="100px">
|
||||||
<el-form-item label="应用代码" prop="app_code">
|
<el-form-item label="应用代码" prop="app_code">
|
||||||
<el-input v-model="form.app_code" :disabled="!!editingId" placeholder="唯一标识,如: brainstorm" />
|
<el-input v-model="form.app_code" :disabled="!!editingId" placeholder="唯一标识,如: brainstorm" />
|
||||||
@@ -275,69 +212,6 @@ onMounted(() => {
|
|||||||
<el-switch v-model="form.require_jssdk" />
|
<el-switch v-model="form.require_jssdk" />
|
||||||
<span style="margin-left: 12px; color: #909399; font-size: 12px">开启后租户需关联企微应用</span>
|
<span style="margin-left: 12px; color: #909399; font-size: 12px">开启后租户需关联企微应用</span>
|
||||||
</el-form-item>
|
</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>
|
</el-form>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<el-button @click="dialogVisible = false">取消</el-button>
|
<el-button @click="dialogVisible = false">取消</el-button>
|
||||||
@@ -351,61 +225,4 @@ onMounted(() => {
|
|||||||
.page-tip {
|
.page-tip {
|
||||||
margin-bottom: 16px;
|
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>
|
</style>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -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>
|
|
||||||
Reference in New Issue
Block a user