1. 修复 SDK 文档 API 路由顺序问题
- 将静态路由 /sdk-docs, /test-script, /secrets 移到动态路由 /{task_id} 之前
- 解决 "请求参数验证失败" 错误
2. 优化错误页面体验
- 使用 sessionStorage 传递错误信息,URL 保持干净
- 使用 router.replace 替代 push,浏览器返回不会停留在错误页
- 记录来源页面,支持正确返回
3. 增强网络错误处理
- 区分超时、网络错误、服务不可用
- 后端未启动时显示友好的 "服务暂时不可用" 提示
4. 添加定时任务模块文档
This commit is contained in:
@@ -75,6 +75,191 @@ class TestScriptRequest(BaseModel):
|
||||
params: Optional[dict] = None
|
||||
|
||||
|
||||
# ==================== Static Routes (must be before dynamic routes) ====================
|
||||
|
||||
@router.get("/sdk-docs")
|
||||
async def get_sdk_docs():
|
||||
"""获取SDK文档"""
|
||||
return {
|
||||
"functions": [
|
||||
{
|
||||
"name": "log",
|
||||
"signature": "log(message: str, level: str = 'INFO')",
|
||||
"description": "记录日志",
|
||||
"example": "log('处理完成', 'INFO')"
|
||||
},
|
||||
{
|
||||
"name": "print",
|
||||
"signature": "print(*args)",
|
||||
"description": "打印输出",
|
||||
"example": "print('Hello', 'World')"
|
||||
},
|
||||
{
|
||||
"name": "ai",
|
||||
"signature": "ai(prompt: str, system: str = None, model: str = None, temperature: float = 0.7)",
|
||||
"description": "调用AI模型",
|
||||
"example": "result = ai('生成一段问候语', system='你是友善的助手')"
|
||||
},
|
||||
{
|
||||
"name": "dingtalk",
|
||||
"signature": "dingtalk(webhook: str, content: str, title: str = None, at_all: bool = False)",
|
||||
"description": "发送钉钉消息",
|
||||
"example": "dingtalk(webhook_url, '# 标题\\n内容')"
|
||||
},
|
||||
{
|
||||
"name": "wecom",
|
||||
"signature": "wecom(webhook: str, content: str, msg_type: str = 'markdown')",
|
||||
"description": "发送企微消息",
|
||||
"example": "wecom(webhook_url, '消息内容')"
|
||||
},
|
||||
{
|
||||
"name": "http_get",
|
||||
"signature": "http_get(url: str, headers: dict = None, params: dict = None)",
|
||||
"description": "发起GET请求",
|
||||
"example": "resp = http_get('https://api.example.com/data')"
|
||||
},
|
||||
{
|
||||
"name": "http_post",
|
||||
"signature": "http_post(url: str, data: any = None, headers: dict = None)",
|
||||
"description": "发起POST请求",
|
||||
"example": "resp = http_post('https://api.example.com/submit', {'key': 'value'})"
|
||||
},
|
||||
{
|
||||
"name": "db_query",
|
||||
"signature": "db_query(sql: str, params: dict = None)",
|
||||
"description": "执行只读SQL查询",
|
||||
"example": "rows = db_query('SELECT * FROM users WHERE status = :status', {'status': 1})"
|
||||
},
|
||||
{
|
||||
"name": "get_var",
|
||||
"signature": "get_var(key: str, default: any = None)",
|
||||
"description": "获取持久化变量",
|
||||
"example": "counter = get_var('counter', 0)"
|
||||
},
|
||||
{
|
||||
"name": "set_var",
|
||||
"signature": "set_var(key: str, value: any)",
|
||||
"description": "设置持久化变量",
|
||||
"example": "set_var('counter', counter + 1)"
|
||||
},
|
||||
{
|
||||
"name": "del_var",
|
||||
"signature": "del_var(key: str)",
|
||||
"description": "删除持久化变量",
|
||||
"example": "del_var('temp_data')"
|
||||
},
|
||||
{
|
||||
"name": "get_param",
|
||||
"signature": "get_param(key: str, default: any = None)",
|
||||
"description": "获取任务参数",
|
||||
"example": "prompt = get_param('prompt', '默认提示词')"
|
||||
},
|
||||
{
|
||||
"name": "get_params",
|
||||
"signature": "get_params()",
|
||||
"description": "获取所有任务参数",
|
||||
"example": "params = get_params()"
|
||||
},
|
||||
{
|
||||
"name": "get_tenants",
|
||||
"signature": "get_tenants(app_code: str = None)",
|
||||
"description": "获取租户列表",
|
||||
"example": "tenants = get_tenants('notification-service')"
|
||||
},
|
||||
{
|
||||
"name": "get_tenant_config",
|
||||
"signature": "get_tenant_config(tenant_id: str, app_code: str, key: str = None)",
|
||||
"description": "获取租户的应用配置",
|
||||
"example": "webhook = get_tenant_config('tenant1', 'notification-service', 'dingtalk_webhook')"
|
||||
},
|
||||
{
|
||||
"name": "get_all_tenant_configs",
|
||||
"signature": "get_all_tenant_configs(app_code: str)",
|
||||
"description": "获取所有租户的应用配置",
|
||||
"example": "configs = get_all_tenant_configs('notification-service')"
|
||||
},
|
||||
{
|
||||
"name": "get_secret",
|
||||
"signature": "get_secret(key: str)",
|
||||
"description": "获取密钥(优先租户级)",
|
||||
"example": "api_key = get_secret('api_key')"
|
||||
}
|
||||
],
|
||||
"variables": [
|
||||
{"name": "task_id", "description": "当前任务ID"},
|
||||
{"name": "tenant_id", "description": "当前租户ID"},
|
||||
{"name": "trace_id", "description": "当前执行追踪ID"}
|
||||
],
|
||||
"libraries": [
|
||||
{"name": "json", "description": "JSON处理"},
|
||||
{"name": "re", "description": "正则表达式"},
|
||||
{"name": "math", "description": "数学函数"},
|
||||
{"name": "random", "description": "随机数"},
|
||||
{"name": "hashlib", "description": "哈希函数"},
|
||||
{"name": "base64", "description": "Base64编解码"},
|
||||
{"name": "datetime", "description": "日期时间处理"},
|
||||
{"name": "timedelta", "description": "时间差"},
|
||||
{"name": "urlencode/quote/unquote", "description": "URL编码"}
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@router.post("/test-script")
|
||||
async def test_script(data: TestScriptRequest, db: Session = Depends(get_db)):
|
||||
"""测试脚本执行"""
|
||||
executor = ScriptExecutor(db)
|
||||
result = executor.test_script(
|
||||
script_content=data.script_content,
|
||||
task_id=0,
|
||||
tenant_id=data.tenant_id,
|
||||
params=data.params
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/secrets")
|
||||
async def list_secrets(
|
||||
tenant_id: Optional[str] = None,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""获取密钥列表"""
|
||||
query = db.query(Secret)
|
||||
if tenant_id:
|
||||
query = query.filter(Secret.tenant_id == tenant_id)
|
||||
|
||||
items = query.order_by(desc(Secret.created_at)).all()
|
||||
|
||||
return {
|
||||
"items": [
|
||||
{
|
||||
"id": s.id,
|
||||
"tenant_id": s.tenant_id,
|
||||
"secret_key": s.secret_key,
|
||||
"description": s.description,
|
||||
"created_at": s.created_at,
|
||||
"updated_at": s.updated_at
|
||||
}
|
||||
for s in items
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@router.post("/secrets")
|
||||
async def create_secret(data: SecretCreate, db: Session = Depends(get_db)):
|
||||
"""创建密钥"""
|
||||
secret = Secret(
|
||||
tenant_id=data.tenant_id,
|
||||
secret_key=data.secret_key,
|
||||
secret_value=data.secret_value,
|
||||
description=data.description
|
||||
)
|
||||
db.add(secret)
|
||||
db.commit()
|
||||
db.refresh(secret)
|
||||
|
||||
return {"success": True, "id": secret.id}
|
||||
|
||||
|
||||
# ==================== Task CRUD ====================
|
||||
|
||||
@router.get("")
|
||||
@@ -272,194 +457,7 @@ async def get_task_logs(
|
||||
}
|
||||
|
||||
|
||||
# ==================== Script Testing ====================
|
||||
|
||||
@router.post("/test-script")
|
||||
async def test_script(data: TestScriptRequest, db: Session = Depends(get_db)):
|
||||
"""测试脚本执行"""
|
||||
executor = ScriptExecutor(db)
|
||||
result = executor.test_script(
|
||||
script_content=data.script_content,
|
||||
task_id=0,
|
||||
tenant_id=data.tenant_id,
|
||||
params=data.params
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
# ==================== SDK Documentation ====================
|
||||
|
||||
@router.get("/sdk-docs")
|
||||
async def get_sdk_docs():
|
||||
"""获取SDK文档"""
|
||||
return {
|
||||
"functions": [
|
||||
{
|
||||
"name": "log",
|
||||
"signature": "log(message: str, level: str = 'INFO')",
|
||||
"description": "记录日志",
|
||||
"example": "log('处理完成', 'INFO')"
|
||||
},
|
||||
{
|
||||
"name": "print",
|
||||
"signature": "print(*args)",
|
||||
"description": "打印输出",
|
||||
"example": "print('Hello', 'World')"
|
||||
},
|
||||
{
|
||||
"name": "ai",
|
||||
"signature": "ai(prompt: str, system: str = None, model: str = None, temperature: float = 0.7)",
|
||||
"description": "调用AI模型",
|
||||
"example": "result = ai('生成一段问候语', system='你是友善的助手')"
|
||||
},
|
||||
{
|
||||
"name": "dingtalk",
|
||||
"signature": "dingtalk(webhook: str, content: str, title: str = None, at_all: bool = False)",
|
||||
"description": "发送钉钉消息",
|
||||
"example": "dingtalk(webhook_url, '# 标题\\n内容')"
|
||||
},
|
||||
{
|
||||
"name": "wecom",
|
||||
"signature": "wecom(webhook: str, content: str, msg_type: str = 'markdown')",
|
||||
"description": "发送企微消息",
|
||||
"example": "wecom(webhook_url, '消息内容')"
|
||||
},
|
||||
{
|
||||
"name": "http_get",
|
||||
"signature": "http_get(url: str, headers: dict = None, params: dict = None)",
|
||||
"description": "发起GET请求",
|
||||
"example": "resp = http_get('https://api.example.com/data')"
|
||||
},
|
||||
{
|
||||
"name": "http_post",
|
||||
"signature": "http_post(url: str, data: any = None, headers: dict = None)",
|
||||
"description": "发起POST请求",
|
||||
"example": "resp = http_post('https://api.example.com/submit', {'key': 'value'})"
|
||||
},
|
||||
{
|
||||
"name": "db_query",
|
||||
"signature": "db_query(sql: str, params: dict = None)",
|
||||
"description": "执行只读SQL查询",
|
||||
"example": "rows = db_query('SELECT * FROM users WHERE status = :status', {'status': 1})"
|
||||
},
|
||||
{
|
||||
"name": "get_var",
|
||||
"signature": "get_var(key: str, default: any = None)",
|
||||
"description": "获取持久化变量",
|
||||
"example": "counter = get_var('counter', 0)"
|
||||
},
|
||||
{
|
||||
"name": "set_var",
|
||||
"signature": "set_var(key: str, value: any)",
|
||||
"description": "设置持久化变量",
|
||||
"example": "set_var('counter', counter + 1)"
|
||||
},
|
||||
{
|
||||
"name": "del_var",
|
||||
"signature": "del_var(key: str)",
|
||||
"description": "删除持久化变量",
|
||||
"example": "del_var('temp_data')"
|
||||
},
|
||||
{
|
||||
"name": "get_param",
|
||||
"signature": "get_param(key: str, default: any = None)",
|
||||
"description": "获取任务参数",
|
||||
"example": "prompt = get_param('prompt', '默认提示词')"
|
||||
},
|
||||
{
|
||||
"name": "get_params",
|
||||
"signature": "get_params()",
|
||||
"description": "获取所有任务参数",
|
||||
"example": "params = get_params()"
|
||||
},
|
||||
{
|
||||
"name": "get_tenants",
|
||||
"signature": "get_tenants(app_code: str = None)",
|
||||
"description": "获取租户列表",
|
||||
"example": "tenants = get_tenants('notification-service')"
|
||||
},
|
||||
{
|
||||
"name": "get_tenant_config",
|
||||
"signature": "get_tenant_config(tenant_id: str, app_code: str, key: str = None)",
|
||||
"description": "获取租户的应用配置",
|
||||
"example": "webhook = get_tenant_config('tenant1', 'notification-service', 'dingtalk_webhook')"
|
||||
},
|
||||
{
|
||||
"name": "get_all_tenant_configs",
|
||||
"signature": "get_all_tenant_configs(app_code: str)",
|
||||
"description": "获取所有租户的应用配置",
|
||||
"example": "configs = get_all_tenant_configs('notification-service')"
|
||||
},
|
||||
{
|
||||
"name": "get_secret",
|
||||
"signature": "get_secret(key: str)",
|
||||
"description": "获取密钥(优先租户级)",
|
||||
"example": "api_key = get_secret('api_key')"
|
||||
}
|
||||
],
|
||||
"variables": [
|
||||
{"name": "task_id", "description": "当前任务ID"},
|
||||
{"name": "tenant_id", "description": "当前租户ID"},
|
||||
{"name": "trace_id", "description": "当前执行追踪ID"}
|
||||
],
|
||||
"libraries": [
|
||||
{"name": "json", "description": "JSON处理"},
|
||||
{"name": "re", "description": "正则表达式"},
|
||||
{"name": "math", "description": "数学函数"},
|
||||
{"name": "random", "description": "随机数"},
|
||||
{"name": "hashlib", "description": "哈希函数"},
|
||||
{"name": "base64", "description": "Base64编解码"},
|
||||
{"name": "datetime", "description": "日期时间处理"},
|
||||
{"name": "timedelta", "description": "时间差"},
|
||||
{"name": "urlencode/quote/unquote", "description": "URL编码"}
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
# ==================== Secrets ====================
|
||||
|
||||
@router.get("/secrets")
|
||||
async def list_secrets(
|
||||
tenant_id: Optional[str] = None,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""获取密钥列表"""
|
||||
query = db.query(Secret)
|
||||
if tenant_id:
|
||||
query = query.filter(Secret.tenant_id == tenant_id)
|
||||
|
||||
items = query.order_by(desc(Secret.created_at)).all()
|
||||
|
||||
return {
|
||||
"items": [
|
||||
{
|
||||
"id": s.id,
|
||||
"tenant_id": s.tenant_id,
|
||||
"secret_key": s.secret_key,
|
||||
"description": s.description,
|
||||
"created_at": s.created_at,
|
||||
"updated_at": s.updated_at
|
||||
}
|
||||
for s in items
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@router.post("/secrets")
|
||||
async def create_secret(data: SecretCreate, db: Session = Depends(get_db)):
|
||||
"""创建密钥"""
|
||||
secret = Secret(
|
||||
tenant_id=data.tenant_id,
|
||||
secret_key=data.secret_key,
|
||||
secret_value=data.secret_value,
|
||||
description=data.description
|
||||
)
|
||||
db.add(secret)
|
||||
db.commit()
|
||||
db.refresh(secret)
|
||||
|
||||
return {"success": True, "id": secret.id}
|
||||
|
||||
# ==================== Secrets (dynamic routes) ====================
|
||||
|
||||
@router.put("/secrets/{secret_id}")
|
||||
async def update_secret(secret_id: int, data: SecretUpdate, db: Session = Depends(get_db)):
|
||||
|
||||
358
docs/scheduled-tasks.md
Normal file
358
docs/scheduled-tasks.md
Normal file
@@ -0,0 +1,358 @@
|
||||
# 定时任务系统文档
|
||||
|
||||
## 功能概述
|
||||
|
||||
平台定时任务系统,支持 Python 脚本或 Webhook 定时执行,执行结果可自动推送到钉钉/企微机器人。
|
||||
|
||||
**核心能力**:
|
||||
- 脚本执行:安全沙箱运行 Python 脚本,内置 AI、HTTP、数据库等 SDK
|
||||
- 调度方式:指定时间点(多选)或 CRON 表达式
|
||||
- 消息推送:支持钉钉/企微机器人所有消息格式(markdown、actionCard、feedCard 等)
|
||||
- 失败处理:支持重试和告警通知
|
||||
|
||||
---
|
||||
|
||||
## 数据库表
|
||||
|
||||
### platform_scheduled_tasks(定时任务表)
|
||||
|
||||
```sql
|
||||
CREATE TABLE platform_scheduled_tasks (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
tenant_id VARCHAR(50) COMMENT '租户ID,空为全局任务',
|
||||
task_name VARCHAR(100) NOT NULL COMMENT '任务名称',
|
||||
task_desc VARCHAR(500) COMMENT '任务描述',
|
||||
schedule_type ENUM('simple', 'cron') NOT NULL DEFAULT 'simple',
|
||||
time_points JSON COMMENT '时间点列表 ["08:00", "12:00"]',
|
||||
cron_expression VARCHAR(100) COMMENT 'CRON表达式',
|
||||
timezone VARCHAR(50) DEFAULT 'Asia/Shanghai',
|
||||
execution_type ENUM('webhook', 'script') NOT NULL DEFAULT 'script',
|
||||
webhook_url VARCHAR(500),
|
||||
script_content TEXT COMMENT 'Python脚本内容',
|
||||
script_deps TEXT COMMENT '脚本依赖',
|
||||
input_params JSON COMMENT '输入参数',
|
||||
retry_count INT DEFAULT 0,
|
||||
retry_interval INT DEFAULT 60,
|
||||
alert_on_failure TINYINT(1) DEFAULT 0,
|
||||
alert_webhook VARCHAR(500),
|
||||
notify_channels JSON COMMENT '通知渠道ID列表',
|
||||
notify_wecom_app_id INT COMMENT '企微应用ID',
|
||||
is_enabled TINYINT(1) DEFAULT 1,
|
||||
last_run_at DATETIME,
|
||||
last_run_status ENUM('success', 'failed', 'running'),
|
||||
last_run_message TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||
);
|
||||
```
|
||||
|
||||
### platform_task_notify_channels(通知渠道表)
|
||||
|
||||
```sql
|
||||
CREATE TABLE platform_task_notify_channels (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
tenant_id VARCHAR(50) NOT NULL COMMENT '租户ID',
|
||||
channel_name VARCHAR(100) NOT NULL COMMENT '渠道名称',
|
||||
channel_type ENUM('dingtalk_bot', 'wecom_bot') NOT NULL,
|
||||
webhook_url VARCHAR(500) NOT NULL,
|
||||
sign_secret VARCHAR(200) COMMENT '钉钉加签密钥',
|
||||
description VARCHAR(255),
|
||||
is_enabled TINYINT(1) DEFAULT 1,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||
);
|
||||
```
|
||||
|
||||
### platform_task_logs(执行日志表)
|
||||
|
||||
```sql
|
||||
CREATE TABLE platform_task_logs (
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||
task_id INT NOT NULL,
|
||||
tenant_id VARCHAR(50),
|
||||
trace_id VARCHAR(100),
|
||||
status ENUM('running', 'success', 'failed'),
|
||||
started_at DATETIME,
|
||||
finished_at DATETIME,
|
||||
duration_ms INT,
|
||||
output TEXT,
|
||||
error TEXT,
|
||||
retry_count INT DEFAULT 0,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 后端文件结构
|
||||
|
||||
```
|
||||
backend/app/
|
||||
├── models/
|
||||
│ ├── scheduled_task.py # ScheduledTask, TaskLog, ScriptVar, Secret 模型
|
||||
│ └── notification_channel.py # TaskNotifyChannel 模型
|
||||
├── routers/
|
||||
│ ├── tasks.py # 定时任务 API (/api/scheduled-tasks)
|
||||
│ └── notification_channels.py # 通知渠道 API (/api/notification-channels)
|
||||
└── services/
|
||||
├── scheduler.py # APScheduler 调度服务
|
||||
├── script_executor.py # 脚本执行器(安全沙箱)
|
||||
└── script_sdk.py # 脚本内置 SDK
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 脚本 SDK 文档
|
||||
|
||||
### 内置函数
|
||||
|
||||
```python
|
||||
# 日志
|
||||
log(message) # 记录日志
|
||||
print(message) # 打印输出
|
||||
|
||||
# AI 调用
|
||||
ai(prompt, system=None, model='deepseek-chat') # 调用 AI
|
||||
|
||||
# 通知发送(直接发送,不走 result)
|
||||
dingtalk(webhook_url, content, title='通知')
|
||||
wecom(webhook_url, content)
|
||||
|
||||
# HTTP 请求
|
||||
http_get(url, headers=None, params=None)
|
||||
http_post(url, data=None, json=None, headers=None)
|
||||
|
||||
# 数据库查询(只读)
|
||||
db_query(sql, params=None)
|
||||
|
||||
# 变量存储(跨执行持久化)
|
||||
get_var(key, default=None)
|
||||
set_var(key, value)
|
||||
del_var(key)
|
||||
|
||||
# 任务参数
|
||||
get_param(key, default=None) # 获取单个参数
|
||||
get_params() # 获取所有参数
|
||||
|
||||
# 租户相关
|
||||
get_tenants() # 获取所有租户
|
||||
get_tenant_config(tenant_id, app_code, key) # 获取租户配置
|
||||
get_all_tenant_configs(app_code, key) # 获取所有租户的配置
|
||||
|
||||
# 密钥
|
||||
get_secret(key) # 获取密钥
|
||||
```
|
||||
|
||||
### 内置变量
|
||||
|
||||
```python
|
||||
task_id # 当前任务ID
|
||||
tenant_id # 当前租户ID(可能为空)
|
||||
trace_id # 追踪ID
|
||||
```
|
||||
|
||||
### 内置模块(无需 import)
|
||||
|
||||
```python
|
||||
datetime # datetime.now(), datetime.strptime()
|
||||
date # date.today()
|
||||
timedelta # timedelta(days=1)
|
||||
time # time.sleep(), time.time()
|
||||
json # json.dumps(), json.loads()
|
||||
re # re.search(), re.match()
|
||||
math # math.ceil(), math.floor()
|
||||
random # random.randint(), random.choice()
|
||||
hashlib # hashlib.md5()
|
||||
base64 # base64.b64encode()
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 消息格式(result 变量)
|
||||
|
||||
### 基础格式(默认 markdown)
|
||||
|
||||
```python
|
||||
result = {
|
||||
'content': 'Markdown 内容',
|
||||
'title': '消息标题'
|
||||
}
|
||||
```
|
||||
|
||||
### 钉钉 ActionCard(交互卡片)
|
||||
|
||||
```python
|
||||
result = {
|
||||
'msg_type': 'actionCard',
|
||||
'title': '卡片标题',
|
||||
'content': '''### 正文内容
|
||||
| 列1 | 列2 |
|
||||
|:---:|:---:|
|
||||
| A | B |
|
||||
''',
|
||||
'btn_orientation': '1', # 0-竖向 1-横向
|
||||
'buttons': [
|
||||
{'title': '按钮1', 'url': 'https://...'},
|
||||
{'title': '按钮2', 'url': 'https://...'}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### 钉钉 FeedCard(信息流)
|
||||
|
||||
```python
|
||||
result = {
|
||||
'msg_type': 'feedCard',
|
||||
'links': [
|
||||
{'title': '标题1', 'url': 'https://...', 'pic_url': 'https://...'},
|
||||
{'title': '标题2', 'url': 'https://...', 'pic_url': 'https://...'}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### 钉钉 Link(链接消息)
|
||||
|
||||
```python
|
||||
result = {
|
||||
'msg_type': 'link',
|
||||
'title': '链接标题',
|
||||
'content': '链接描述',
|
||||
'url': 'https://...',
|
||||
'pic_url': 'https://...'
|
||||
}
|
||||
```
|
||||
|
||||
### 企微 News(图文消息)
|
||||
|
||||
```python
|
||||
result = {
|
||||
'msg_type': 'news',
|
||||
'articles': [
|
||||
{
|
||||
'title': '文章标题',
|
||||
'description': '文章描述',
|
||||
'url': 'https://...',
|
||||
'picurl': 'https://...'
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### 企微 Template Card(模板卡片)
|
||||
|
||||
```python
|
||||
result = {
|
||||
'msg_type': 'template_card',
|
||||
'card_type': 'text_notice', # text_notice / news_notice / button_interaction
|
||||
'title': '卡片标题',
|
||||
'content': '卡片内容',
|
||||
'horizontal_list': [
|
||||
{'keyname': '申请人', 'value': '张三'},
|
||||
{'keyname': '金额', 'value': '¥5,000'}
|
||||
],
|
||||
'jump_list': [
|
||||
{'type': 1, 'title': '查看详情', 'url': 'https://...'}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## API 端点
|
||||
|
||||
### 定时任务
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| GET | /api/scheduled-tasks | 任务列表 |
|
||||
| GET | /api/scheduled-tasks/{id} | 任务详情 |
|
||||
| POST | /api/scheduled-tasks | 创建任务 |
|
||||
| PUT | /api/scheduled-tasks/{id} | 更新任务 |
|
||||
| DELETE | /api/scheduled-tasks/{id} | 删除任务 |
|
||||
| POST | /api/scheduled-tasks/{id}/toggle | 启用/禁用 |
|
||||
| POST | /api/scheduled-tasks/{id}/run | 立即执行 |
|
||||
| GET | /api/scheduled-tasks/{id}/logs | 执行日志 |
|
||||
| POST | /api/scheduled-tasks/test-script | 测试脚本 |
|
||||
| GET | /api/scheduled-tasks/sdk-docs | SDK 文档 |
|
||||
|
||||
### 通知渠道
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| GET | /api/notification-channels | 渠道列表 |
|
||||
| POST | /api/notification-channels | 创建渠道 |
|
||||
| PUT | /api/notification-channels/{id} | 更新渠道 |
|
||||
| DELETE | /api/notification-channels/{id} | 删除渠道 |
|
||||
| POST | /api/notification-channels/{id}/test | 测试渠道 |
|
||||
|
||||
---
|
||||
|
||||
## 前端文件
|
||||
|
||||
```
|
||||
frontend/src/views/
|
||||
├── scheduled-tasks/
|
||||
│ └── index.vue # 定时任务管理页面
|
||||
└── notification-channels/
|
||||
└── index.vue # 通知渠道管理页面
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 示例脚本
|
||||
|
||||
### 基础示例
|
||||
|
||||
```python
|
||||
# 无需 import,模块已内置
|
||||
log('任务开始执行')
|
||||
|
||||
now = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
||||
prompt = get_param('prompt', '默认提示词')
|
||||
|
||||
content = ai(prompt, system='你是一个助手')
|
||||
|
||||
result = {
|
||||
'title': '每日推送',
|
||||
'content': f'**生成时间**: {now}\n\n{content}'
|
||||
}
|
||||
|
||||
log('任务执行完成')
|
||||
```
|
||||
|
||||
### 复杂卡片示例
|
||||
|
||||
```python
|
||||
log('生成销售日报')
|
||||
|
||||
now = datetime.now()
|
||||
today = now.strftime('%Y年%m月%d日')
|
||||
|
||||
# 模拟数据
|
||||
revenue = random.randint(50000, 150000)
|
||||
|
||||
result = {
|
||||
'msg_type': 'actionCard',
|
||||
'title': f'销售日报 | {today}',
|
||||
'content': f'''### 今日业绩
|
||||
|
||||
| 指标 | 数值 |
|
||||
|:---:|:---:|
|
||||
| 销售额 | **¥{revenue:,}** |
|
||||
| 订单数 | **{random.randint(40, 80)}** |
|
||||
|
||||
> 点击查看详情
|
||||
''',
|
||||
'buttons': [
|
||||
{'title': '查看详情', 'url': 'https://example.com/report'}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 部署信息
|
||||
|
||||
- **测试环境**: https://platform.test.ai.ireborn.com.cn
|
||||
- **数据库**: new_qiqi (测试) / new_platform_prod (生产)
|
||||
- **Docker 容器**: platform-backend-test / platform-frontend-test
|
||||
@@ -18,9 +18,21 @@ function parseApiError(error) {
|
||||
status: 500
|
||||
}
|
||||
|
||||
// 网络错误(后端未启动、网络断开等)
|
||||
if (!error.response) {
|
||||
if (error.code === 'ECONNABORTED') {
|
||||
result.code = 'TIMEOUT_ERROR'
|
||||
result.message = '请求超时,请稍后重试'
|
||||
result.status = 0
|
||||
} else if (error.message?.includes('Network Error')) {
|
||||
result.code = 'SERVICE_UNAVAILABLE'
|
||||
result.message = '服务暂时不可用,请稍后重试'
|
||||
result.status = 503
|
||||
} else {
|
||||
result.code = 'NETWORK_ERROR'
|
||||
result.message = '网络连接失败,请检查网络后重试'
|
||||
result.status = 0
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -40,18 +52,23 @@ function parseApiError(error) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 跳转到错误页面
|
||||
* 跳转到错误页面(使用 sessionStorage + replace,不影响浏览器历史)
|
||||
*/
|
||||
function navigateToErrorPage(errorInfo) {
|
||||
router.push({
|
||||
name: 'Error',
|
||||
query: {
|
||||
// 记录当前页面路径(用于返回)
|
||||
sessionStorage.setItem('errorFromPath', router.currentRoute.value.fullPath)
|
||||
|
||||
// 保存错误信息到 sessionStorage(不会显示在 URL 中)
|
||||
sessionStorage.setItem('errorInfo', JSON.stringify({
|
||||
code: errorInfo.code,
|
||||
message: errorInfo.message,
|
||||
trace_id: errorInfo.traceId,
|
||||
status: String(errorInfo.status)
|
||||
}
|
||||
})
|
||||
traceId: errorInfo.traceId,
|
||||
status: errorInfo.status,
|
||||
timestamp: Date.now()
|
||||
}))
|
||||
|
||||
// 使用 replace 而不是 push,这样浏览器返回时不会停留在错误页
|
||||
router.replace({ name: 'Error' })
|
||||
}
|
||||
|
||||
// 请求拦截器
|
||||
@@ -75,6 +92,15 @@ api.interceptors.response.use(
|
||||
|
||||
console.error(`[API Error] ${errorInfo.code}: ${errorInfo.message}${traceLog}`)
|
||||
|
||||
// 严重错误列表(跳转错误页)
|
||||
const criticalErrors = [
|
||||
'INTERNAL_ERROR',
|
||||
'SERVICE_UNAVAILABLE',
|
||||
'GATEWAY_ERROR',
|
||||
'NETWORK_ERROR',
|
||||
'TIMEOUT_ERROR'
|
||||
]
|
||||
|
||||
if (error.response?.status === 401) {
|
||||
localStorage.removeItem('token')
|
||||
localStorage.removeItem('user')
|
||||
@@ -82,8 +108,8 @@ api.interceptors.response.use(
|
||||
ElMessage.error('登录已过期,请重新登录')
|
||||
} else if (error.response?.status === 403) {
|
||||
ElMessage.error('没有权限执行此操作')
|
||||
} else if (['INTERNAL_ERROR', 'SERVICE_UNAVAILABLE', 'GATEWAY_ERROR'].includes(errorInfo.code)) {
|
||||
// 严重错误跳转到错误页面
|
||||
} else if (criticalErrors.includes(errorInfo.code)) {
|
||||
// 严重错误(包括网络错误、服务不可用)跳转到错误页面
|
||||
navigateToErrorPage(errorInfo)
|
||||
} else {
|
||||
// 普通错误显示消息
|
||||
|
||||
@@ -1,23 +1,27 @@
|
||||
<script setup>
|
||||
/**
|
||||
* 统一错误页面
|
||||
* - 从 sessionStorage 读取错误信息,不污染 URL
|
||||
* - 使用 replace 跳转,支持浏览器返回
|
||||
*/
|
||||
import { computed, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ref, onMounted, onUnmounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElButton, ElMessage } from 'element-plus'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const errorCode = computed(() => route.query.code || 'UNKNOWN_ERROR')
|
||||
const errorMessage = computed(() => route.query.message || '发生了未知错误')
|
||||
const traceId = computed(() => route.query.trace_id || '')
|
||||
const statusCode = computed(() => route.query.status || '500')
|
||||
|
||||
// 错误信息
|
||||
const errorCode = ref('UNKNOWN_ERROR')
|
||||
const errorMessage = ref('发生了未知错误')
|
||||
const traceId = ref('')
|
||||
const statusCode = ref('500')
|
||||
const copied = ref(false)
|
||||
|
||||
const errorConfig = computed(() => {
|
||||
const configs = {
|
||||
// 记录来源页面(用于返回)
|
||||
const fromPath = ref('')
|
||||
|
||||
// 错误类型配置
|
||||
const errorConfigs = {
|
||||
'UNAUTHORIZED': { icon: 'Lock', title: '访问受限', color: '#e67e22' },
|
||||
'FORBIDDEN': { icon: 'CircleClose', title: '权限不足', color: '#e74c3c' },
|
||||
'NOT_FOUND': { icon: 'Search', title: '页面未找到', color: '#3498db' },
|
||||
@@ -25,9 +29,41 @@ const errorConfig = computed(() => {
|
||||
'INTERNAL_ERROR': { icon: 'Close', title: '服务器错误', color: '#e74c3c' },
|
||||
'SERVICE_UNAVAILABLE': { icon: 'Tools', title: '服务暂时不可用', color: '#9b59b6' },
|
||||
'NETWORK_ERROR': { icon: 'Connection', title: '网络连接失败', color: '#95a5a6' },
|
||||
'TIMEOUT_ERROR': { icon: 'Timer', title: '请求超时', color: '#95a5a6' },
|
||||
'UNKNOWN_ERROR': { icon: 'QuestionFilled', title: '未知错误', color: '#7f8c8d' }
|
||||
}
|
||||
|
||||
const errorConfig = ref(errorConfigs['UNKNOWN_ERROR'])
|
||||
|
||||
onMounted(() => {
|
||||
// 从 sessionStorage 读取错误信息
|
||||
const stored = sessionStorage.getItem('errorInfo')
|
||||
if (stored) {
|
||||
try {
|
||||
const info = JSON.parse(stored)
|
||||
// 检查时效性(5分钟内有效)
|
||||
if (Date.now() - info.timestamp < 5 * 60 * 1000) {
|
||||
errorCode.value = info.code || 'UNKNOWN_ERROR'
|
||||
errorMessage.value = info.message || '发生了未知错误'
|
||||
traceId.value = info.traceId || ''
|
||||
statusCode.value = String(info.status || 500)
|
||||
errorConfig.value = errorConfigs[errorCode.value] || errorConfigs['UNKNOWN_ERROR']
|
||||
}
|
||||
return configs[errorCode.value] || configs['UNKNOWN_ERROR']
|
||||
// 读取后清除(避免刷新时重复显示旧错误)
|
||||
sessionStorage.removeItem('errorInfo')
|
||||
} catch (e) {
|
||||
console.error('Failed to parse error info', e)
|
||||
}
|
||||
}
|
||||
|
||||
// 记录来源页面
|
||||
fromPath.value = sessionStorage.getItem('errorFromPath') || '/dashboard'
|
||||
sessionStorage.removeItem('errorFromPath')
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
// 确保清理
|
||||
sessionStorage.removeItem('errorInfo')
|
||||
})
|
||||
|
||||
const copyTraceId = async () => {
|
||||
@@ -44,7 +80,15 @@ const copyTraceId = async () => {
|
||||
}
|
||||
|
||||
const goHome = () => router.push('/dashboard')
|
||||
const retry = () => router.back()
|
||||
|
||||
// 返回之前的页面
|
||||
const goBack = () => {
|
||||
if (fromPath.value && fromPath.value !== '/error') {
|
||||
router.push(fromPath.value)
|
||||
} else {
|
||||
router.push('/dashboard')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -72,7 +116,7 @@ const retry = () => router.back()
|
||||
</div>
|
||||
|
||||
<div class="action-buttons">
|
||||
<el-button type="primary" @click="retry">重试</el-button>
|
||||
<el-button type="primary" @click="goBack">返回</el-button>
|
||||
<el-button @click="goHome">返回首页</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user