Compare commits

..

33 Commits

Author SHA1 Message Date
28baed1cad refactor: 改造 CI/CD 使用阿里云 ACR 镜像仓库
All checks were successful
continuous-integration/drone/push Build is passing
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-03 17:15:04 +08:00
facb854e3d feat: 租户详情页添加瑞美云配置 Tab
All checks were successful
continuous-integration/drone/push Build is passing
- 提供友好的表单界面配置瑞美云连接信息
- 支持保存配置和测试连接
- 私钥加密存储

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-03 11:43:22 +08:00
afcf30b519 feat: 新增睿美云对接模块
All checks were successful
continuous-integration/drone/push Build is passing
- 扩展 ToolConfig 配置类型,新增 external_api 类型
- 实现接口注册表,包含 90+ 睿美云开放接口定义
- 实现 TPOS SHA256WithRSA 签名鉴权
- 实现睿美云 API 客户端,支持多租户配置
- 新增代理路由 /api/ruimeiyun/call/{api_name}
- 支持接口权限控制和健康检查
2026-01-30 17:27:58 +08:00
c1ba17f809 fix: 恢复租户应用订阅的自定义配置功能
All checks were successful
continuous-integration/drone/push Build is passing
定时任务提交(104487f)误删了 custom_configs 相关代码,现恢复:
- backend/app/models/tenant_app.py: 恢复 custom_configs 数据库字段
- backend/app/routers/tenant_apps.py: 恢复 CustomConfigItem、custom_configs 逻辑、get_token API
2026-01-29 17:47:57 +08:00
830361073b fix: 恢复应用管理的配置项定义功能
All checks were successful
continuous-integration/drone/push Build is passing
定时任务提交(104487f)误删了应用 config_schema 相关代码,现恢复:
- backend/app/models/app.py: 恢复 config_schema 数据库字段
- backend/app/routers/apps.py: 恢复 ConfigSchemaItem、API 路由、格式化函数
- frontend/src/views/apps/index.vue: 恢复配置项编辑 UI
2026-01-29 17:45:43 +08:00
66e6dc844c fix: 修正租户应用订阅页面恢复版本
All checks were successful
continuous-integration/drone/push Build is passing
修复上次恢复时使用了错误的版本(95a9d3e),缺少后续功能:
- 长文本配置项预览+弹窗编辑功能
- 长文本编辑弹窗接近全屏显示
- 长文本编辑区域高度问题修复
- 弹窗打开时锁定背景页面滚动

现在使用正确的版本(e45fe81,定时任务提交前的最后正确版本)
2026-01-29 17:42:21 +08:00
158481ff75 fix: 恢复租户应用订阅页面被误删的功能
All checks were successful
continuous-integration/drone/push Build is passing
恢复在 104487f 提交中被误删的代码,包括:
- 批量添加应用订阅功能
- 租户标签快速筛选
- 应用配置 Schema 支持
- 自定义配置管理
- 批量 Token 结果显示
2026-01-29 16:24:27 +08:00
b0f7d1ba9e fix: 修复定时任务模块问题
All checks were successful
continuous-integration/drone/push Build is passing
1. 修复 SDK 文档 API 路由顺序问题
   - 将静态路由 /sdk-docs, /test-script, /secrets 移到动态路由 /{task_id} 之前
   - 解决 "请求参数验证失败" 错误

2. 优化错误页面体验
   - 使用 sessionStorage 传递错误信息,URL 保持干净
   - 使用 router.replace 替代 push,浏览器返回不会停留在错误页
   - 记录来源页面,支持正确返回

3. 增强网络错误处理
   - 区分超时、网络错误、服务不可用
   - 后端未启动时显示友好的 "服务暂时不可用" 提示

4. 添加定时任务模块文档
2026-01-28 18:18:04 +08:00
262a1b409f fix: 修复脚本全屏编辑对话框超出屏幕高度问题
All checks were successful
continuous-integration/drone/push Build is passing
使用 CSS calc(80vh - 180px) 控制 textarea 高度
2026-01-28 17:59:13 +08:00
d91119af8a feat: 编辑任务对话框使用 Tab 分类重构
All checks were successful
continuous-integration/drone/push Build is passing
1. 使用 Tabs 分类组织内容:
   - 基本信息:名称、租户、类型、调度
   - 脚本配置:脚本内容、参数
   - 通知与高级:通知渠道、重试、告警

2. 固定对话框高度,内部滚动,避免整体滚动体验差
2026-01-28 17:55:55 +08:00
d57f812513 feat: 定时任务页面 UI 优化
All checks were successful
continuous-integration/drone/push Build is passing
1. 脚本编辑:增加全屏编辑按钮,打开大弹窗编辑
2. 执行时间:改为时间选择器 + 标签方式,支持可视化添加多个时间点
3. 任务参数:改为 Key-Value 表格形式,支持添加/删除,更直观
2026-01-28 17:52:31 +08:00
97d0aac734 feat: 扩展消息类型支持钉钉/企微所有格式
All checks were successful
continuous-integration/drone/push Build is passing
钉钉机器人支持:
- text: 纯文本(支持@人)
- markdown: Markdown格式
- link: 链接消息
- actionCard: 交互卡片(整体跳转/独立跳转按钮)
- feedCard: 信息流卡片

企微机器人支持:
- text: 纯文本(支持@人)
- markdown: Markdown格式
- image: 图片
- news: 图文消息
- template_card: 模板卡片(文本通知/图文展示/按钮交互)

使用方式: result = {'msg_type': 'actionCard', 'title': '...', 'content': '...', 'buttons': [...]}
2026-01-28 17:44:01 +08:00
3cf5451597 fix: 设置后端容器时区为 Asia/Shanghai
All checks were successful
continuous-integration/drone/push Build is passing
2026-01-28 17:39:40 +08:00
3ebd8b20a4 fix: 添加受限的 __import__ 函数支持白名单模块导入
All checks were successful
continuous-integration/drone/push Build is passing
解决脚本执行时 KeyError: '__import__' 错误
2026-01-28 17:34:38 +08:00
70fc358d72 fix: 更新脚本示例,说明模块已内置无需 import
All checks were successful
continuous-integration/drone/push Build is passing
2026-01-28 17:27:43 +08:00
d7380bdc75 fix: 修复定时任务页面租户下拉字段不匹配
All checks were successful
continuous-integration/drone/push Build is passing
2026-01-28 17:24:55 +08:00
333bbe57eb feat: 钉钉机器人支持加签安全设置
All checks were successful
continuous-integration/drone/push Build is passing
- 通知渠道增加 sign_secret 字段存储加签密钥
- 发送钉钉消息时自动计算签名
- 前端增加加签密钥输入框(仅钉钉机器人显示)
2026-01-28 17:19:53 +08:00
8430f9dbaa fix: 修复通知渠道页面租户下拉字段不匹配
All checks were successful
continuous-integration/drone/push Build is passing
租户 API 返回 code/name,修正前端使用正确字段
2026-01-28 17:13:49 +08:00
b8e19dcde6 fix: 重命名通知渠道模型避免与 alert 模块冲突
All checks were successful
continuous-integration/drone/push Build is passing
- NotificationChannel -> TaskNotifyChannel
- platform_notification_channels -> platform_task_notify_channels
2026-01-28 17:06:28 +08:00
2fbba63884 feat: 实现通知渠道管理功能
All checks were successful
continuous-integration/drone/push Build is passing
- 新增 platform_notification_channels 表管理通知渠道(钉钉/企微机器人)
- 新增通知渠道管理页面,支持创建、编辑、测试、删除
- 定时任务增加通知渠道选择和企微应用选择
- 脚本执行支持返回值(result变量),自动发送到配置的渠道
- 调度器执行脚本后根据配置自动发送通知

使用方式:
1. 在「通知渠道」页面为租户配置钉钉/企微机器人
2. 创建定时任务时选择通知渠道
3. 脚本中设置 result = {'content': '内容', 'title': '标题'}
4. 任务执行后自动发送到配置的渠道
2026-01-28 17:02:20 +08:00
d9fa9708ce fix: 修复定时任务模型字段与数据库表不匹配的问题
All checks were successful
continuous-integration/drone/push Build is passing
- task_type -> execution_type
- status -> is_enabled
- 移除不存在的字段 webhook_method, webhook_headers, script_timeout
- time_points/input_params 适配 JSON 类型
2026-01-28 16:45:58 +08:00
104487f082 feat: 实现定时任务系统
All checks were successful
continuous-integration/drone/push Build is passing
- 新增 platform_scheduled_tasks, platform_task_logs, platform_script_vars, platform_secrets 数据库表
- 实现 ScriptSDK 提供 AI/通知/DB/HTTP/变量存储/参数获取等功能
- 实现安全的脚本执行器,支持沙箱环境和禁止危险操作
- 实现 APScheduler 调度服务,支持简单时间点和 CRON 表达式
- 新增定时任务 API 路由,包含 CRUD、执行、日志、密钥管理
- 新增定时任务前端页面,支持脚本编辑、测试运行、日志查看
2026-01-28 16:38:19 +08:00
7806072b17 fix: 删除冲突的 codemirror 依赖,只保留 monaco-editor
All checks were successful
continuous-integration/drone/push Build is passing
2026-01-28 14:57:23 +08:00
2f9d85edb6 feat: 脚本管理页面(类似青龙面板)
Some checks failed
continuous-integration/drone/push Build is failing
- 新增脚本管理页面,左右分栏布局
- 集成 Monaco Editor 代码编辑器(语法高亮、行号、快捷键)
- 支持脚本 CRUD、运行、复制等操作
- 定时任务支持从脚本库导入脚本
- 新增 platform_scripts 表存储脚本
2026-01-28 13:13:08 +08:00
9b72e6127f feat: 脚本执行平台增强功能
Some checks failed
continuous-integration/drone/push Build is failing
- 新增重试和失败告警功能(支持自动重试N次,失败后钉钉/企微通知)
- 新增密钥管理(安全存储API Key等敏感信息)
- 新增脚本模板库(预置常用脚本模板)
- 新增脚本版本管理(自动保存历史版本,支持回滚)
- 新增执行统计(成功率、平均耗时、7日趋势)
- SDK 新增多租户遍历能力(get_tenants/get_tenant_config/get_all_tenant_configs)
- SDK 新增密钥读取方法(get_secret)
2026-01-28 11:59:50 +08:00
644255891e feat: 脚本执行平台功能
Some checks failed
continuous-integration/drone/push Build is failing
- 支持 Python 脚本定时执行(类似青龙面板)
- 内置 SDK:AI大模型、钉钉/企微通知、数据库查询、HTTP请求、变量存储
- 安全沙箱执行,禁用危险模块
- 前端脚本编辑器,支持测试执行
- SDK 文档查看
- 日志通过 TraceID 与 platform_logs 关联
2026-01-28 11:45:02 +08:00
ed88099cf0 feat: 定时任务调度功能
All checks were successful
continuous-integration/drone/push Build is passing
- 新增 platform_scheduled_tasks 和 platform_task_logs 数据表
- 实现 APScheduler 调度器服务(支持简单模式和CRON表达式)
- 添加定时任务 CRUD API
- 支持手动触发执行和查看执行日志
- 前端任务管理页面
2026-01-28 11:27:42 +08:00
e45fe8128c fix: 弹窗打开时锁定背景页面滚动
All checks were successful
continuous-integration/drone/push Build is passing
2026-01-28 11:14:16 +08:00
29f031ca61 fix: 修复长文本编辑区域高度问题
All checks were successful
continuous-integration/drone/push Build is passing
2026-01-28 11:11:57 +08:00
c67ace3576 fix: 长文本编辑弹窗改为接近全屏显示
All checks were successful
continuous-integration/drone/push Build is passing
2026-01-28 11:10:16 +08:00
3c7903078c feat: 优化长文本配置项显示 - 预览+弹窗编辑
All checks were successful
continuous-integration/drone/push Build is passing
2026-01-28 11:05:16 +08:00
95a9d3e15d feat: 租户应用订阅支持批量添加全部应用
All checks were successful
continuous-integration/drone/push Build is passing
2026-01-28 11:01:43 +08:00
22cc89a6bd feat: 租户应用订阅页面优化 - 标签筛选、中文显示、快捷复制链接
All checks were successful
continuous-integration/drone/push Build is passing
2026-01-28 10:28:37 +08:00
28 changed files with 7263 additions and 543 deletions

View File

@@ -1,6 +1,6 @@
kind: pipeline
type: docker
name: build-and-deploy
name: build-and-push
trigger:
branch:
@@ -10,88 +10,66 @@ trigger:
- push
steps:
# 构建后端镜像
- name: build-backend
image: docker:dind
volumes:
- name: docker-sock
path: /var/run/docker.sock
commands:
- docker build -t platform-backend:${DRONE_COMMIT_SHA:0:8} -f deploy/Dockerfile.backend .
- docker tag platform-backend:${DRONE_COMMIT_SHA:0:8} platform-backend:latest
# 构建前端镜像(测试环境)
- name: build-frontend-test
image: docker:dind
volumes:
- name: docker-sock
path: /var/run/docker.sock
commands:
- docker build -t platform-frontend-test:${DRONE_COMMIT_SHA:0:8} -f deploy/Dockerfile.frontend --build-arg BACKEND_HOST=platform-backend-test .
- docker tag platform-frontend-test:${DRONE_COMMIT_SHA:0:8} platform-frontend-test:latest
when:
branch:
- develop
# 构建前端镜像(生产环境)
- name: build-frontend-prod
image: docker:dind
volumes:
- name: docker-sock
path: /var/run/docker.sock
commands:
- docker build -t platform-frontend-prod:${DRONE_COMMIT_SHA:0:8} -f deploy/Dockerfile.frontend --build-arg BACKEND_HOST=platform-backend-prod .
- docker tag platform-frontend-prod:${DRONE_COMMIT_SHA:0:8} platform-frontend-prod:latest
when:
branch:
- main
# 部署测试环境
- name: deploy-test
# 构建并推送后端镜像
- name: build-push-backend
image: docker:dind
volumes:
- name: docker-sock
path: /var/run/docker.sock
environment:
DATABASE_URL:
from_secret: database_url
API_KEY:
from_secret: api_key
JWT_SECRET:
from_secret: jwt_secret
CONFIG_ENCRYPT_KEY:
from_secret: config_encrypt_key
DOCKER_REGISTRY:
from_secret: docker_registry
DOCKER_USERNAME:
from_secret: docker_username
DOCKER_PASSWORD:
from_secret: docker_password
commands:
- docker network create platform-network 2>/dev/null || true
- docker stop platform-backend-test platform-frontend-test || true
- docker rm platform-backend-test platform-frontend-test || true
- docker run -d --name platform-backend-test --network platform-network -p 8001:8000 --restart unless-stopped -e DATABASE_URL=$DATABASE_URL -e API_KEY=$API_KEY -e JWT_SECRET=$JWT_SECRET -e CONFIG_ENCRYPT_KEY=$CONFIG_ENCRYPT_KEY platform-backend:latest
- docker run -d --name platform-frontend-test --network platform-network -p 3003:80 --restart unless-stopped platform-frontend-test:latest
when:
branch:
- develop
- echo "$DOCKER_PASSWORD" | docker login "$DOCKER_REGISTRY" -u "$DOCKER_USERNAME" --password-stdin
- docker build -t $DOCKER_REGISTRY/ireborn/platform-backend:${DRONE_BRANCH} -f deploy/Dockerfile.backend .
- docker tag $DOCKER_REGISTRY/ireborn/platform-backend:${DRONE_BRANCH} $DOCKER_REGISTRY/ireborn/platform-backend:latest
- docker tag $DOCKER_REGISTRY/ireborn/platform-backend:${DRONE_BRANCH} $DOCKER_REGISTRY/ireborn/platform-backend:${DRONE_COMMIT_SHA:0:8}
- docker push $DOCKER_REGISTRY/ireborn/platform-backend:${DRONE_BRANCH}
- docker push $DOCKER_REGISTRY/ireborn/platform-backend:latest
- docker push $DOCKER_REGISTRY/ireborn/platform-backend:${DRONE_COMMIT_SHA:0:8}
# 部署生产环境(使用独立的生产数据库
- name: deploy-prod
# 构建并推送前端镜像(测试环境
- name: build-push-frontend-test
image: docker:dind
volumes:
- name: docker-sock
path: /var/run/docker.sock
environment:
DATABASE_URL:
from_secret: database_url_prod
API_KEY:
from_secret: api_key
JWT_SECRET:
from_secret: jwt_secret
CONFIG_ENCRYPT_KEY:
from_secret: config_encrypt_key
DOCKER_REGISTRY:
from_secret: docker_registry
DOCKER_USERNAME:
from_secret: docker_username
DOCKER_PASSWORD:
from_secret: docker_password
commands:
- docker network create platform-network-prod 2>/dev/null || true
- docker stop platform-backend-prod platform-frontend-prod || true
- docker rm platform-backend-prod platform-frontend-prod || true
- docker run -d --name platform-backend-prod --network platform-network-prod -p 9001:8000 --restart unless-stopped -e DATABASE_URL=$DATABASE_URL -e API_KEY=$API_KEY -e JWT_SECRET=$JWT_SECRET -e CONFIG_ENCRYPT_KEY=$CONFIG_ENCRYPT_KEY platform-backend:latest
- docker run -d --name platform-frontend-prod --network platform-network-prod -p 4003:80 --restart unless-stopped platform-frontend-prod:latest
- echo "$DOCKER_PASSWORD" | docker login "$DOCKER_REGISTRY" -u "$DOCKER_USERNAME" --password-stdin
- docker build -t $DOCKER_REGISTRY/ireborn/platform-frontend:develop -f deploy/Dockerfile.frontend --build-arg BACKEND_HOST=platform-backend-test .
- docker push $DOCKER_REGISTRY/ireborn/platform-frontend:develop
when:
branch:
- develop
# 构建并推送前端镜像(生产环境)
- name: build-push-frontend-prod
image: docker:dind
volumes:
- name: docker-sock
path: /var/run/docker.sock
environment:
DOCKER_REGISTRY:
from_secret: docker_registry
DOCKER_USERNAME:
from_secret: docker_username
DOCKER_PASSWORD:
from_secret: docker_password
commands:
- echo "$DOCKER_PASSWORD" | docker login "$DOCKER_REGISTRY" -u "$DOCKER_USERNAME" --password-stdin
- docker build -t $DOCKER_REGISTRY/ireborn/platform-frontend:main -f deploy/Dockerfile.frontend --build-arg BACKEND_HOST=platform-backend-prod .
- docker push $DOCKER_REGISTRY/ireborn/platform-frontend:main
when:
branch:
- main

View File

@@ -14,9 +14,13 @@ from .routers.wechat import router as wechat_router
from .routers.alerts import router as alerts_router
from .routers.cost import router as cost_router
from .routers.quota import router as quota_router
from .routers.tasks import router as tasks_router
from .routers.notification_channels import router as notification_channels_router
from .routers.tool_configs import router as tool_configs_router
from .routers.ruimeiyun import router as ruimeiyun_router
from .middleware import TraceMiddleware, setup_exception_handlers, RequestLoggerMiddleware
from .middleware.trace import setup_logging
from .services.scheduler import scheduler_service
# 配置日志(包含 TraceID
setup_logging(level=logging.INFO, include_trace=True)
@@ -67,7 +71,23 @@ app.include_router(wechat_router, prefix="/api")
app.include_router(alerts_router, prefix="/api")
app.include_router(cost_router, prefix="/api")
app.include_router(quota_router, prefix="/api")
app.include_router(tasks_router)
app.include_router(notification_channels_router)
app.include_router(tool_configs_router, prefix="/api")
app.include_router(ruimeiyun_router, prefix="/api")
# 应用生命周期事件
@app.on_event("startup")
async def startup_event():
"""应用启动时启动调度器"""
scheduler_service.start()
@app.on_event("shutdown")
async def shutdown_event():
"""应用关闭时关闭调度器"""
scheduler_service.shutdown()
@app.get("/")

View File

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

View File

@@ -0,0 +1,21 @@
"""任务通知渠道模型"""
from datetime import datetime
from sqlalchemy import Column, Integer, String, Enum, Boolean, DateTime
from ..database import Base
class TaskNotifyChannel(Base):
"""任务通知渠道表(用于定时任务推送)"""
__tablename__ = "platform_task_notify_channels"
id = Column(Integer, primary_key=True, autoincrement=True)
tenant_id = Column(String(50), nullable=False)
channel_name = Column(String(100), nullable=False)
channel_type = Column(Enum('dingtalk_bot', 'wecom_bot'), nullable=False)
webhook_url = Column(String(500), nullable=False)
sign_secret = Column(String(200)) # 钉钉加签密钥
description = Column(String(255))
is_enabled = Column(Boolean, default=True)
created_at = Column(DateTime, default=datetime.now)
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)

View File

@@ -0,0 +1,103 @@
"""定时任务相关模型"""
from datetime import datetime
from sqlalchemy import Column, BigInteger, Integer, String, Text, Enum, SmallInteger, TIMESTAMP, DateTime, JSON, Boolean
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_desc = Column(String(500))
# 调度配置
schedule_type = Column(Enum('simple', 'cron'), nullable=False, default='simple')
time_points = Column(JSON) # JSON数组 ["08:00", "12:00"]
cron_expression = Column(String(100))
timezone = Column(String(50), default='Asia/Shanghai')
# 执行类型
execution_type = Column(Enum('webhook', 'script'), nullable=False, default='script')
# Webhook配置
webhook_url = Column(String(500))
# 脚本配置
script_content = Column(Text)
script_deps = Column(Text) # 脚本依赖
# 输入参数
input_params = Column(JSON) # JSON格式
# 重试配置
retry_count = Column(Integer, default=0)
retry_interval = Column(Integer, default=60)
# 告警配置
alert_on_failure = Column(Boolean, default=False)
alert_webhook = Column(String(500))
# 通知配置
notify_channels = Column(JSON) # 通知渠道ID列表
notify_wecom_app_id = Column(Integer) # 企微应用ID
# 状态
is_enabled = Column(Boolean, default=True)
last_run_at = Column(DateTime)
last_run_status = Column(Enum('success', 'failed', 'running'))
last_run_message = Column(Text)
created_at = Column(DateTime, default=datetime.now)
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
class TaskLog(Base):
"""任务执行日志"""
__tablename__ = "platform_task_logs"
id = Column(BigInteger, primary_key=True, autoincrement=True)
task_id = Column(Integer, nullable=False)
tenant_id = Column(String(50))
trace_id = Column(String(100))
status = Column(Enum('running', 'success', 'failed'), nullable=False)
started_at = Column(DateTime, nullable=False)
finished_at = Column(DateTime)
duration_ms = Column(Integer)
output = Column(Text)
error = Column(Text)
retry_count = Column(Integer, default=0)
created_at = Column(TIMESTAMP, default=datetime.now)
class ScriptVar(Base):
"""脚本变量存储"""
__tablename__ = "platform_script_vars"
id = Column(Integer, primary_key=True, autoincrement=True)
task_id = Column(Integer, nullable=False)
tenant_id = Column(String(50))
var_key = Column(String(100), nullable=False)
var_value = Column(Text) # JSON格式
created_at = Column(TIMESTAMP, default=datetime.now)
updated_at = Column(TIMESTAMP, default=datetime.now, onupdate=datetime.now)
class Secret(Base):
"""密钥管理"""
__tablename__ = "platform_secrets"
id = Column(Integer, primary_key=True, autoincrement=True)
tenant_id = Column(String(50)) # NULL为全局
secret_key = Column(String(100), nullable=False)
secret_value = Column(Text, nullable=False)
description = Column(String(255))
created_at = Column(TIMESTAMP, default=datetime.now)
updated_at = Column(TIMESTAMP, default=datetime.now, onupdate=datetime.now)

View File

@@ -0,0 +1,211 @@
"""通知渠道API路由"""
from typing import Optional, List
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel
from sqlalchemy.orm import Session
from sqlalchemy import desc
from ..database import get_db
from ..models.notification_channel import TaskNotifyChannel
router = APIRouter(prefix="/api/notification-channels", tags=["notification-channels"])
# ==================== Schemas ====================
class ChannelCreate(BaseModel):
tenant_id: str
channel_name: str
channel_type: str # dingtalk_bot, wecom_bot
webhook_url: str
sign_secret: Optional[str] = None # 钉钉加签密钥
description: Optional[str] = None
class ChannelUpdate(BaseModel):
channel_name: Optional[str] = None
channel_type: Optional[str] = None
webhook_url: Optional[str] = None
sign_secret: Optional[str] = None
description: Optional[str] = None
is_enabled: Optional[bool] = None
# ==================== CRUD ====================
@router.get("")
async def list_channels(
tenant_id: Optional[str] = None,
channel_type: Optional[str] = None,
is_enabled: Optional[bool] = None,
page: int = Query(1, ge=1),
size: int = Query(50, ge=1, le=100),
db: Session = Depends(get_db)
):
"""获取通知渠道列表"""
query = db.query(TaskNotifyChannel)
if tenant_id:
query = query.filter(TaskNotifyChannel.tenant_id == tenant_id)
if channel_type:
query = query.filter(TaskNotifyChannel.channel_type == channel_type)
if is_enabled is not None:
query = query.filter(TaskNotifyChannel.is_enabled == is_enabled)
total = query.count()
items = query.order_by(desc(TaskNotifyChannel.created_at)).offset((page - 1) * size).limit(size).all()
return {
"total": total,
"items": [format_channel(c) for c in items]
}
@router.get("/{channel_id}")
async def get_channel(channel_id: int, db: Session = Depends(get_db)):
"""获取渠道详情"""
channel = db.query(TaskNotifyChannel).filter(TaskNotifyChannel.id == channel_id).first()
if not channel:
raise HTTPException(status_code=404, detail="渠道不存在")
return format_channel(channel)
@router.post("")
async def create_channel(data: ChannelCreate, db: Session = Depends(get_db)):
"""创建通知渠道"""
channel = TaskNotifyChannel(
tenant_id=data.tenant_id,
channel_name=data.channel_name,
channel_type=data.channel_type,
webhook_url=data.webhook_url,
sign_secret=data.sign_secret,
description=data.description,
is_enabled=True
)
db.add(channel)
db.commit()
db.refresh(channel)
return {"success": True, "id": channel.id}
@router.put("/{channel_id}")
async def update_channel(channel_id: int, data: ChannelUpdate, db: Session = Depends(get_db)):
"""更新通知渠道"""
channel = db.query(TaskNotifyChannel).filter(TaskNotifyChannel.id == channel_id).first()
if not channel:
raise HTTPException(status_code=404, detail="渠道不存在")
if data.channel_name is not None:
channel.channel_name = data.channel_name
if data.channel_type is not None:
channel.channel_type = data.channel_type
if data.webhook_url is not None:
channel.webhook_url = data.webhook_url
if data.sign_secret is not None:
channel.sign_secret = data.sign_secret
if data.description is not None:
channel.description = data.description
if data.is_enabled is not None:
channel.is_enabled = data.is_enabled
db.commit()
return {"success": True}
@router.delete("/{channel_id}")
async def delete_channel(channel_id: int, db: Session = Depends(get_db)):
"""删除通知渠道"""
channel = db.query(TaskNotifyChannel).filter(TaskNotifyChannel.id == channel_id).first()
if not channel:
raise HTTPException(status_code=404, detail="渠道不存在")
db.delete(channel)
db.commit()
return {"success": True}
@router.post("/{channel_id}/test")
async def test_channel(channel_id: int, db: Session = Depends(get_db)):
"""测试通知渠道"""
import httpx
import time
import hmac
import hashlib
import base64
import urllib.parse
channel = db.query(TaskNotifyChannel).filter(TaskNotifyChannel.id == channel_id).first()
if not channel:
raise HTTPException(status_code=404, detail="渠道不存在")
test_content = f"**测试消息**\n\n渠道名称: {channel.channel_name}\n发送时间: 测试中..."
try:
url = channel.webhook_url
if channel.channel_type == 'dingtalk_bot':
# 钉钉加签
if channel.sign_secret:
timestamp = str(round(time.time() * 1000))
string_to_sign = f'{timestamp}\n{channel.sign_secret}'
hmac_code = hmac.new(
channel.sign_secret.encode('utf-8'),
string_to_sign.encode('utf-8'),
digestmod=hashlib.sha256
).digest()
sign = urllib.parse.quote_plus(base64.b64encode(hmac_code))
# 拼接签名参数
if '?' in url:
url = f"{url}&timestamp={timestamp}&sign={sign}"
else:
url = f"{url}?timestamp={timestamp}&sign={sign}"
payload = {
"msgtype": "markdown",
"markdown": {
"title": "渠道测试",
"text": test_content
}
}
else: # wecom_bot
payload = {
"msgtype": "markdown",
"markdown": {
"content": test_content
}
}
async with httpx.AsyncClient(timeout=10) as client:
response = await client.post(url, json=payload)
result = response.json()
# 钉钉返回 errcode=0企微返回 errcode=0
if result.get('errcode') == 0:
return {"success": True, "message": "发送成功"}
else:
return {"success": False, "message": f"发送失败: {result}"}
except Exception as e:
return {"success": False, "message": f"发送失败: {str(e)}"}
# ==================== Helpers ====================
def format_channel(channel: TaskNotifyChannel) -> dict:
"""格式化渠道数据"""
return {
"id": channel.id,
"tenant_id": channel.tenant_id,
"channel_name": channel.channel_name,
"channel_type": channel.channel_type,
"webhook_url": channel.webhook_url,
"sign_secret": channel.sign_secret,
"description": channel.description,
"is_enabled": channel.is_enabled,
"created_at": channel.created_at,
"updated_at": channel.updated_at
}

View File

@@ -0,0 +1,287 @@
"""
睿美云代理路由
提供统一的睿美云接口代理能力,支持:
- 多租户配置隔离
- 接口权限控制
- 统一日志记录
- 错误处理
"""
import logging
from typing import Optional, Dict, Any, List
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel
from sqlalchemy.orm import Session
from ..database import get_db
from ..services.ruimeiyun import RuimeiyunClient, RUIMEIYUN_APIS, get_api_definition
from ..services.ruimeiyun.client import RuimeiyunError
from ..services.ruimeiyun.registry import get_all_modules, get_api_list_by_module, get_api_summary
from .auth import get_current_user
from ..models.user import User
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/ruimeiyun", tags=["睿美云代理"])
# ========================================
# Schemas
# ========================================
class RuimeiyunCallRequest(BaseModel):
"""睿美云接口调用请求"""
params: Optional[Dict[str, Any]] = None # URL 参数
body: Optional[Dict[str, Any]] = None # 请求体
class RuimeiyunRawCallRequest(BaseModel):
"""睿美云原始接口调用请求"""
method: str # HTTP 方法
path: str # API 路径
params: Optional[Dict[str, Any]] = None
body: Optional[Dict[str, Any]] = None
# ========================================
# API Endpoints
# ========================================
@router.post("/call/{api_name}")
async def call_ruimeiyun_api(
api_name: str,
request: RuimeiyunCallRequest,
tenant_id: str = Query(..., description="租户ID"),
user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""
调用睿美云接口(通过接口名称)
Args:
api_name: 接口名称,如 customer.search, order.list
request: 请求参数
tenant_id: 租户ID
Returns:
睿美云接口返回的数据
示例:
POST /api/ruimeiyun/call/customer.search?tenant_id=xxx
Body: {"params": {"keyword": "13800138000", "page": 1, "size": 20}}
"""
try:
client = RuimeiyunClient(tenant_id, db)
result = await client.call(
api_name=api_name,
params=request.params,
body=request.body
)
if result.success:
return {
"success": True,
"data": result.data
}
else:
return {
"success": False,
"error": result.error,
"raw": result.raw_response
}
except RuimeiyunError as e:
logger.error(f"睿美云调用失败: {api_name}, {e}")
raise HTTPException(status_code=e.status_code, detail=str(e))
except Exception as e:
logger.exception(f"睿美云调用异常: {api_name}")
raise HTTPException(status_code=500, detail=f"调用失败: {str(e)}")
@router.post("/call-raw")
async def call_ruimeiyun_raw(
request: RuimeiyunRawCallRequest,
tenant_id: str = Query(..., description="租户ID"),
user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""
直接调用睿美云接口(通过路径)
用于调用未在注册表中定义的接口
Args:
request: 请求参数(包含 method, path, params, body
tenant_id: 租户ID
Returns:
睿美云接口返回的数据
示例:
POST /api/ruimeiyun/call-raw?tenant_id=xxx
Body: {
"method": "GET",
"path": "/api/v1/tpos/customer/customer-search",
"params": {"keyword": "13800138000"}
}
"""
try:
client = RuimeiyunClient(tenant_id, db)
result = await client.call_raw(
method=request.method,
path=request.path,
params=request.params,
body=request.body
)
if result.success:
return {
"success": True,
"data": result.data
}
else:
return {
"success": False,
"error": result.error,
"raw": result.raw_response
}
except RuimeiyunError as e:
logger.error(f"睿美云调用失败: {request.path}, {e}")
raise HTTPException(status_code=e.status_code, detail=str(e))
except Exception as e:
logger.exception(f"睿美云调用异常: {request.path}")
raise HTTPException(status_code=500, detail=f"调用失败: {str(e)}")
# ========================================
# 接口元数据
# ========================================
@router.get("/apis")
async def list_apis(
module: Optional[str] = None,
user: User = Depends(get_current_user)
):
"""
获取可用的接口列表
Args:
module: 模块名称(可选),如 customer, order
Returns:
接口列表
"""
if module:
apis = get_api_list_by_module(module)
return {
"module": module,
"count": len(apis),
"apis": apis
}
else:
return {
"count": len(RUIMEIYUN_APIS),
"summary": get_api_summary(),
"apis": RUIMEIYUN_APIS
}
@router.get("/apis/{api_name}")
async def get_api_info(
api_name: str,
user: User = Depends(get_current_user)
):
"""
获取接口详情
Args:
api_name: 接口名称,如 customer.search
Returns:
接口定义
"""
api_def = get_api_definition(api_name)
if not api_def:
raise HTTPException(status_code=404, detail=f"接口不存在: {api_name}")
return {
"name": api_name,
**api_def
}
@router.get("/modules")
async def list_modules(
user: User = Depends(get_current_user)
):
"""
获取所有模块列表
Returns:
模块名称列表和每个模块的接口数量
"""
modules = get_all_modules()
summary = get_api_summary()
return {
"modules": [
{"name": m, "count": summary.get(m, 0)}
for m in modules
]
}
# ========================================
# 健康检查
# ========================================
@router.get("/health/{tenant_id}")
async def check_ruimeiyun_health(
tenant_id: str,
user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""
检查租户的睿美云连接状态
Args:
tenant_id: 租户ID
Returns:
连接状态信息
"""
try:
client = RuimeiyunClient(tenant_id, db)
# 调用门店列表接口测试连接
result = await client.call("tenant.list")
if result.success:
return {
"status": "connected",
"tenant_id": tenant_id,
"base_url": client.config.base_url,
"account": client.config.account,
"message": "连接正常"
}
else:
return {
"status": "error",
"tenant_id": tenant_id,
"message": result.error
}
except RuimeiyunError as e:
return {
"status": "error",
"tenant_id": tenant_id,
"message": str(e)
}
except Exception as e:
return {
"status": "error",
"tenant_id": tenant_id,
"message": f"检查失败: {str(e)}"
}

View File

@@ -0,0 +1,559 @@
"""定时任务API路由"""
import json
from datetime import datetime
from typing import Optional, List
from fastapi import APIRouter, Depends, HTTPException, Query, BackgroundTasks
from pydantic import BaseModel
from sqlalchemy.orm import Session
from sqlalchemy import desc
from ..database import get_db
from ..models.scheduled_task import ScheduledTask, TaskLog, Secret
from ..services.scheduler import scheduler_service
from ..services.script_executor import ScriptExecutor
router = APIRouter(prefix="/api/scheduled-tasks", tags=["scheduled-tasks"])
# ==================== Schemas ====================
class TaskCreate(BaseModel):
tenant_id: Optional[str] = None
task_name: str
task_desc: Optional[str] = None
execution_type: str = 'script'
schedule_type: str = 'simple'
time_points: Optional[List[str]] = None
cron_expression: Optional[str] = None
webhook_url: Optional[str] = None
script_content: Optional[str] = None
input_params: Optional[dict] = None
retry_count: Optional[int] = 0
retry_interval: Optional[int] = 60
alert_on_failure: Optional[bool] = False
alert_webhook: Optional[str] = None
notify_channels: Optional[List[int]] = None # 通知渠道ID列表
notify_wecom_app_id: Optional[int] = None # 企微应用ID
class TaskUpdate(BaseModel):
tenant_id: Optional[str] = None
task_name: Optional[str] = None
task_desc: Optional[str] = None
execution_type: Optional[str] = None
schedule_type: Optional[str] = None
time_points: Optional[List[str]] = None
cron_expression: Optional[str] = None
webhook_url: Optional[str] = None
script_content: Optional[str] = None
input_params: Optional[dict] = None
retry_count: Optional[int] = None
retry_interval: Optional[int] = None
alert_on_failure: Optional[bool] = None
alert_webhook: Optional[str] = None
is_enabled: Optional[bool] = None
notify_channels: Optional[List[int]] = None
notify_wecom_app_id: Optional[int] = None
class SecretCreate(BaseModel):
tenant_id: Optional[str] = None
secret_key: str
secret_value: str
description: Optional[str] = None
class SecretUpdate(BaseModel):
secret_value: Optional[str] = None
description: Optional[str] = None
class TestScriptRequest(BaseModel):
script_content: str
tenant_id: Optional[str] = None
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("")
async def list_tasks(
tenant_id: Optional[str] = None,
status: Optional[int] = None,
page: int = Query(1, ge=1),
size: int = Query(20, ge=1, le=100),
db: Session = Depends(get_db)
):
"""获取任务列表"""
query = db.query(ScheduledTask)
if tenant_id:
query = query.filter(ScheduledTask.tenant_id == tenant_id)
if status is not None:
is_enabled = status == 1
query = query.filter(ScheduledTask.is_enabled == is_enabled)
total = query.count()
items = query.order_by(desc(ScheduledTask.created_at)).offset((page - 1) * size).limit(size).all()
return {
"total": total,
"items": [format_task(t) for t in items]
}
@router.get("/{task_id}")
async def get_task(task_id: int, db: Session = Depends(get_db)):
"""获取任务详情"""
task = db.query(ScheduledTask).filter(ScheduledTask.id == task_id).first()
if not task:
raise HTTPException(status_code=404, detail="任务不存在")
return format_task(task, include_content=True)
@router.post("")
async def create_task(data: TaskCreate, db: Session = Depends(get_db)):
"""创建任务"""
task = ScheduledTask(
tenant_id=data.tenant_id,
task_name=data.task_name,
task_desc=data.task_desc,
execution_type=data.execution_type,
schedule_type=data.schedule_type,
time_points=data.time_points,
cron_expression=data.cron_expression,
webhook_url=data.webhook_url,
script_content=data.script_content,
input_params=data.input_params,
retry_count=data.retry_count,
retry_interval=data.retry_interval,
alert_on_failure=data.alert_on_failure,
alert_webhook=data.alert_webhook,
notify_channels=data.notify_channels,
notify_wecom_app_id=data.notify_wecom_app_id,
is_enabled=True
)
db.add(task)
db.commit()
db.refresh(task)
# 添加到调度器
scheduler_service.add_task(task.id)
return {"success": True, "id": task.id}
@router.put("/{task_id}")
async def update_task(task_id: int, data: TaskUpdate, db: Session = Depends(get_db)):
"""更新任务"""
task = db.query(ScheduledTask).filter(ScheduledTask.id == task_id).first()
if not task:
raise HTTPException(status_code=404, detail="任务不存在")
# 更新字段
if data.tenant_id is not None:
task.tenant_id = data.tenant_id
if data.task_name is not None:
task.task_name = data.task_name
if data.task_desc is not None:
task.task_desc = data.task_desc
if data.execution_type is not None:
task.execution_type = data.execution_type
if data.schedule_type is not None:
task.schedule_type = data.schedule_type
if data.time_points is not None:
task.time_points = data.time_points
if data.cron_expression is not None:
task.cron_expression = data.cron_expression
if data.webhook_url is not None:
task.webhook_url = data.webhook_url
if data.script_content is not None:
task.script_content = data.script_content
if data.input_params is not None:
task.input_params = data.input_params
if data.retry_count is not None:
task.retry_count = data.retry_count
if data.retry_interval is not None:
task.retry_interval = data.retry_interval
if data.alert_on_failure is not None:
task.alert_on_failure = data.alert_on_failure
if data.alert_webhook is not None:
task.alert_webhook = data.alert_webhook
if data.notify_channels is not None:
task.notify_channels = data.notify_channels
if data.notify_wecom_app_id is not None:
task.notify_wecom_app_id = data.notify_wecom_app_id
if data.is_enabled is not None:
task.is_enabled = data.is_enabled
db.commit()
# 更新调度器
if task.is_enabled:
scheduler_service.add_task(task.id)
else:
scheduler_service.remove_task(task.id)
return {"success": True}
@router.delete("/{task_id}")
async def delete_task(task_id: int, db: Session = Depends(get_db)):
"""删除任务"""
task = db.query(ScheduledTask).filter(ScheduledTask.id == task_id).first()
if not task:
raise HTTPException(status_code=404, detail="任务不存在")
# 从调度器移除
scheduler_service.remove_task(task_id)
# 删除相关日志
db.query(TaskLog).filter(TaskLog.task_id == task_id).delete()
db.delete(task)
db.commit()
return {"success": True}
@router.post("/{task_id}/toggle")
async def toggle_task(task_id: int, db: Session = Depends(get_db)):
"""启用/禁用任务"""
task = db.query(ScheduledTask).filter(ScheduledTask.id == task_id).first()
if not task:
raise HTTPException(status_code=404, detail="任务不存在")
task.is_enabled = not task.is_enabled
db.commit()
if task.is_enabled:
scheduler_service.add_task(task.id)
else:
scheduler_service.remove_task(task.id)
return {"success": True, "status": 1 if task.is_enabled else 0}
@router.post("/{task_id}/run")
async def run_task(task_id: int, background_tasks: BackgroundTasks, db: Session = Depends(get_db)):
"""立即执行任务"""
task = db.query(ScheduledTask).filter(ScheduledTask.id == task_id).first()
if not task:
raise HTTPException(status_code=404, detail="任务不存在")
result = await scheduler_service.run_task_now(task_id)
return result
# ==================== Task Logs ====================
@router.get("/{task_id}/logs")
async def get_task_logs(
task_id: int,
status: Optional[str] = None,
page: int = Query(1, ge=1),
size: int = Query(20, ge=1, le=100),
db: Session = Depends(get_db)
):
"""获取任务执行日志"""
query = db.query(TaskLog).filter(TaskLog.task_id == task_id)
if status:
query = query.filter(TaskLog.status == status)
total = query.count()
items = query.order_by(desc(TaskLog.started_at)).offset((page - 1) * size).limit(size).all()
return {
"total": total,
"items": [format_log(log) for log in items]
}
# ==================== Secrets (dynamic routes) ====================
@router.put("/secrets/{secret_id}")
async def update_secret(secret_id: int, data: SecretUpdate, db: Session = Depends(get_db)):
"""更新密钥"""
secret = db.query(Secret).filter(Secret.id == secret_id).first()
if not secret:
raise HTTPException(status_code=404, detail="密钥不存在")
if data.secret_value is not None:
secret.secret_value = data.secret_value
if data.description is not None:
secret.description = data.description
db.commit()
return {"success": True}
@router.delete("/secrets/{secret_id}")
async def delete_secret(secret_id: int, db: Session = Depends(get_db)):
"""删除密钥"""
secret = db.query(Secret).filter(Secret.id == secret_id).first()
if not secret:
raise HTTPException(status_code=404, detail="密钥不存在")
db.delete(secret)
db.commit()
return {"success": True}
# ==================== Helpers ====================
def format_task(task: ScheduledTask, include_content: bool = False) -> dict:
"""格式化任务数据"""
time_points = task.time_points
if isinstance(time_points, str):
try:
time_points = json.loads(time_points)
except:
time_points = []
# 处理 notify_channels
notify_channels = task.notify_channels
if isinstance(notify_channels, str):
try:
notify_channels = json.loads(notify_channels)
except:
notify_channels = []
data = {
"id": task.id,
"tenant_id": task.tenant_id,
"task_name": task.task_name,
"task_type": task.execution_type, # 前端使用 task_type
"schedule_type": task.schedule_type,
"time_points": time_points or [],
"cron_expression": task.cron_expression,
"status": 1 if task.is_enabled else 0, # 前端使用 status
"last_run_at": task.last_run_at,
"last_run_status": task.last_run_status,
"retry_count": task.retry_count,
"retry_interval": task.retry_interval,
"alert_on_failure": bool(task.alert_on_failure),
"alert_webhook": task.alert_webhook,
"notify_channels": notify_channels or [],
"notify_wecom_app_id": task.notify_wecom_app_id,
"created_at": task.created_at,
"updated_at": task.updated_at
}
if include_content:
data["webhook_url"] = task.webhook_url
data["script_content"] = task.script_content
input_params = task.input_params
if isinstance(input_params, str):
try:
input_params = json.loads(input_params)
except:
input_params = None
data["input_params"] = input_params
return data
def format_log(log: TaskLog) -> dict:
"""格式化日志数据"""
return {
"id": log.id,
"task_id": log.task_id,
"tenant_id": log.tenant_id,
"trace_id": log.trace_id,
"status": log.status,
"started_at": log.started_at,
"finished_at": log.finished_at,
"duration_ms": log.duration_ms,
"output": log.output,
"error": log.error,
"retry_count": log.retry_count,
"created_at": log.created_at
}

View File

@@ -354,7 +354,8 @@ async def get_config_types():
{"code": "datasource", "name": "数据源配置", "description": "数据库连接等"},
{"code": "jssdk", "name": "JS-SDK 配置", "description": "企微侧边栏等"},
{"code": "webhook", "name": "Webhook 配置", "description": "n8n 工作流地址等"},
{"code": "params", "name": "工具参数", "description": "各工具的自定义参数"}
{"code": "params", "name": "工具参数", "description": "各工具的自定义参数"},
{"code": "external_api", "name": "外部API配置", "description": "睿美云等外部系统对接"}
]
}
@@ -387,5 +388,11 @@ async def get_config_keys():
{"key": "default_data_tenant_id", "name": "默认数据租户ID", "encrypted": False},
{"key": "enable_deep_thinking", "name": "启用深度思考", "encrypted": False},
{"key": "max_history_rounds", "name": "最大历史轮数", "encrypted": False}
],
"external_api": [
{"key": "ruimeiyun_base_url", "name": "睿美云 API 地址", "encrypted": False},
{"key": "ruimeiyun_account", "name": "睿美云 TPOS 账号", "encrypted": False},
{"key": "ruimeiyun_private_key", "name": "睿美云 RSA 私钥", "encrypted": True},
{"key": "ruimeiyun_allowed_apis", "name": "允许的接口列表(JSON)", "encrypted": False}
]
}

View File

@@ -0,0 +1,10 @@
"""
睿美云对接服务
提供睿美云开放接口的代理调用能力,支持多租户配置。
"""
from .client import RuimeiyunClient
from .registry import RUIMEIYUN_APIS, get_api_definition
__all__ = ["RuimeiyunClient", "RUIMEIYUN_APIS", "get_api_definition"]

View File

@@ -0,0 +1,124 @@
"""
睿美云 TPOS 鉴权
实现睿美云开放接口的身份验证机制:
- tpos-timestamp: 请求时间戳(秒级)
- tpos-account: 账号
- tpos-nonce-str: 随机字符串
- tpos-sign: SHA256WithRSA 签名
签名算法:
1. 组合待签名字符串: {timestamp}&{nonce_str}
2. 使用私钥进行 SHA256WithRSA 签名
3. Base64 编码签名结果
"""
import time
import uuid
import base64
import logging
from typing import Dict
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import padding
from cryptography.hazmat.backends import default_backend
logger = logging.getLogger(__name__)
class TposAuthError(Exception):
"""TPOS 鉴权错误"""
pass
def build_tpos_headers(account: str, private_key_pem: str) -> Dict[str, str]:
"""
构建 TPOS 鉴权请求头
Args:
account: TPOS 账号(由睿美云提供)
private_key_pem: RSA 私钥PEM 格式)
Returns:
包含鉴权信息的请求头字典
Raises:
TposAuthError: 签名失败时抛出
"""
try:
# 1. 生成时间戳和随机字符串
timestamp = str(int(time.time()))
nonce_str = uuid.uuid4().hex
# 2. 组合待签名字符串
sign_content = f"{timestamp}&{nonce_str}"
# 3. 加载私钥
private_key = serialization.load_pem_private_key(
private_key_pem.encode('utf-8'),
password=None,
backend=default_backend()
)
# 4. SHA256WithRSA 签名
signature = private_key.sign(
sign_content.encode('utf-8'),
padding.PKCS1v15(),
hashes.SHA256()
)
# 5. Base64 编码
sign_base64 = base64.b64encode(signature).decode('utf-8')
return {
"tpos-timestamp": timestamp,
"tpos-account": account,
"tpos-nonce-str": nonce_str,
"tpos-sign": sign_base64
}
except Exception as e:
logger.error(f"TPOS 签名失败: {e}")
raise TposAuthError(f"签名失败: {str(e)}")
def validate_private_key(private_key_pem: str) -> bool:
"""
验证私钥格式是否正确
Args:
private_key_pem: RSA 私钥PEM 格式)
Returns:
True 如果私钥有效,否则 False
"""
try:
serialization.load_pem_private_key(
private_key_pem.encode('utf-8'),
password=None,
backend=default_backend()
)
return True
except Exception as e:
logger.warning(f"私钥验证失败: {e}")
return False
def mask_private_key(private_key_pem: str, show_chars: int = 50) -> str:
"""
对私钥进行脱敏处理,用于日志显示
Args:
private_key_pem: RSA 私钥PEM 格式)
show_chars: 显示的字符数
Returns:
脱敏后的字符串
"""
if not private_key_pem:
return ""
if len(private_key_pem) <= show_chars * 2:
return "****"
return f"{private_key_pem[:show_chars]}...****...{private_key_pem[-show_chars:]}"

View File

@@ -0,0 +1,325 @@
"""
睿美云 API 客户端
提供统一的睿美云接口调用能力:
- 自动加载租户配置
- 自动构建 TPOS 鉴权头
- 统一错误处理
- 请求日志记录
"""
import json
import logging
from typing import Dict, Any, Optional, List
from dataclasses import dataclass
import httpx
from sqlalchemy.orm import Session
from .auth import build_tpos_headers, TposAuthError
from .registry import get_api_definition, RUIMEIYUN_APIS
from ..crypto import decrypt_value
logger = logging.getLogger(__name__)
# 请求超时设置
DEFAULT_TIMEOUT = 30.0 # 秒
LONG_TIMEOUT = 60.0 # 长时间操作
@dataclass
class RuimeiyunConfig:
"""睿美云配置"""
base_url: str
account: str
private_key: str
allowed_apis: Optional[List[str]] = None
@dataclass
class RuimeiyunResponse:
"""睿美云响应"""
success: bool
data: Optional[Any] = None
error: Optional[str] = None
status_code: int = 200
raw_response: Optional[Dict] = None
class RuimeiyunError(Exception):
"""睿美云调用错误"""
def __init__(self, message: str, status_code: int = 500, response: Any = None):
super().__init__(message)
self.status_code = status_code
self.response = response
class RuimeiyunClient:
"""
睿美云 API 客户端
使用方式:
client = RuimeiyunClient(tenant_id, db)
result = await client.call("customer.search", params={"keyword": "13800138000"})
"""
def __init__(self, tenant_id: str, db: Session):
"""
初始化客户端
Args:
tenant_id: 租户ID
db: 数据库会话
"""
self.tenant_id = tenant_id
self.db = db
self.config = self._load_config()
def _load_config(self) -> RuimeiyunConfig:
"""从数据库加载租户的睿美云配置"""
from ...models.tool_config import ToolConfig
# 查询租户的睿美云配置
configs = self.db.query(ToolConfig).filter(
ToolConfig.tenant_id == self.tenant_id,
ToolConfig.tool_code == "ruimeiyun",
ToolConfig.config_type == "external_api",
ToolConfig.status == 1
).all()
if not configs:
raise RuimeiyunError(
f"租户 {self.tenant_id} 未配置睿美云连接信息",
status_code=400
)
# 转换为字典
config_dict = {}
for c in configs:
value = c.config_value
# 解密加密字段
if c.is_encrypted and value:
try:
value = decrypt_value(value)
except Exception as e:
logger.error(f"解密配置失败: {c.config_key}, {e}")
raise RuimeiyunError(f"配置解密失败: {c.config_key}")
config_dict[c.config_key] = value
# 验证必填配置
required = ["ruimeiyun_base_url", "ruimeiyun_account", "ruimeiyun_private_key"]
for key in required:
if not config_dict.get(key):
raise RuimeiyunError(f"缺少必填配置: {key}", status_code=400)
# 解析允许的接口列表
allowed_apis = None
if config_dict.get("ruimeiyun_allowed_apis"):
try:
allowed_apis = json.loads(config_dict["ruimeiyun_allowed_apis"])
except json.JSONDecodeError:
logger.warning(f"解析 allowed_apis 失败: {config_dict.get('ruimeiyun_allowed_apis')}")
return RuimeiyunConfig(
base_url=config_dict["ruimeiyun_base_url"].rstrip("/"),
account=config_dict["ruimeiyun_account"],
private_key=config_dict["ruimeiyun_private_key"],
allowed_apis=allowed_apis
)
def _check_permission(self, api_name: str):
"""检查是否有权限调用该接口"""
if self.config.allowed_apis is not None:
if api_name not in self.config.allowed_apis:
raise RuimeiyunError(
f"租户无权调用接口: {api_name}",
status_code=403
)
async def call(
self,
api_name: str,
params: Optional[Dict[str, Any]] = None,
body: Optional[Dict[str, Any]] = None,
timeout: float = DEFAULT_TIMEOUT
) -> RuimeiyunResponse:
"""
调用睿美云接口
Args:
api_name: 接口名称,如 customer.search
params: URL 查询参数
body: 请求体POST 请求)
timeout: 超时时间(秒)
Returns:
RuimeiyunResponse 对象
Raises:
RuimeiyunError: 调用失败时抛出
"""
# 1. 获取接口定义
api_def = get_api_definition(api_name)
if not api_def:
raise RuimeiyunError(f"未知接口: {api_name}", status_code=400)
# 2. 检查权限
self._check_permission(api_name)
# 3. 构建请求
method = api_def["method"]
url = f"{self.config.base_url}{api_def['path']}"
# 4. 构建鉴权头
try:
auth_headers = build_tpos_headers(
self.config.account,
self.config.private_key
)
except TposAuthError as e:
raise RuimeiyunError(str(e), status_code=500)
headers = {
**auth_headers,
"Content-Type": "application/json"
}
# 5. 发送请求
logger.info(f"调用睿美云接口: {api_name} ({method} {api_def['path']})")
try:
async with httpx.AsyncClient(timeout=timeout) as client:
if method == "GET":
response = await client.get(url, params=params, headers=headers)
else:
response = await client.post(url, params=params, json=body, headers=headers)
# 6. 处理响应
status_code = response.status_code
try:
response_data = response.json()
except Exception:
response_data = {"raw": response.text}
# 睿美云响应格式通常为: {"code": 0, "data": ..., "msg": "success"}
if status_code == 200:
# 检查业务状态码
code = response_data.get("code")
if code == 0 or code == "0" or code is None:
return RuimeiyunResponse(
success=True,
data=response_data.get("data", response_data),
status_code=status_code,
raw_response=response_data
)
else:
return RuimeiyunResponse(
success=False,
error=response_data.get("msg", response_data.get("message", "未知错误")),
status_code=status_code,
raw_response=response_data
)
else:
return RuimeiyunResponse(
success=False,
error=f"HTTP {status_code}: {response_data}",
status_code=status_code,
raw_response=response_data
)
except httpx.TimeoutException:
logger.error(f"睿美云接口超时: {api_name}")
raise RuimeiyunError(f"接口超时: {api_name}", status_code=504)
except httpx.RequestError as e:
logger.error(f"睿美云接口请求错误: {api_name}, {e}")
raise RuimeiyunError(f"请求错误: {str(e)}", status_code=502)
async def call_raw(
self,
method: str,
path: str,
params: Optional[Dict[str, Any]] = None,
body: Optional[Dict[str, Any]] = None,
timeout: float = DEFAULT_TIMEOUT
) -> RuimeiyunResponse:
"""
直接调用睿美云接口(不经过注册表)
用于调用未在注册表中定义的接口
Args:
method: HTTP 方法
path: API 路径
params: URL 查询参数
body: 请求体
timeout: 超时时间
Returns:
RuimeiyunResponse 对象
"""
url = f"{self.config.base_url}{path}"
try:
auth_headers = build_tpos_headers(
self.config.account,
self.config.private_key
)
except TposAuthError as e:
raise RuimeiyunError(str(e), status_code=500)
headers = {
**auth_headers,
"Content-Type": "application/json"
}
logger.info(f"调用睿美云接口(raw): {method} {path}")
try:
async with httpx.AsyncClient(timeout=timeout) as client:
response = await client.request(
method=method,
url=url,
params=params,
json=body if method != "GET" else None,
headers=headers
)
try:
response_data = response.json()
except Exception:
response_data = {"raw": response.text}
if response.status_code == 200:
code = response_data.get("code")
if code == 0 or code == "0" or code is None:
return RuimeiyunResponse(
success=True,
data=response_data.get("data", response_data),
status_code=response.status_code,
raw_response=response_data
)
else:
return RuimeiyunResponse(
success=False,
error=response_data.get("msg", "未知错误"),
status_code=response.status_code,
raw_response=response_data
)
else:
return RuimeiyunResponse(
success=False,
error=f"HTTP {response.status_code}",
status_code=response.status_code,
raw_response=response_data
)
except httpx.TimeoutException:
raise RuimeiyunError("接口超时", status_code=504)
except httpx.RequestError as e:
raise RuimeiyunError(f"请求错误: {str(e)}", status_code=502)
@staticmethod
def get_available_apis() -> Dict[str, Any]:
"""获取所有可用的接口列表"""
return RUIMEIYUN_APIS

View File

@@ -0,0 +1,885 @@
"""
睿美云接口注册表
定义所有可用的睿美云开放接口,包括:
- 接口路径
- 请求方法
- 参数说明
- 接口分组
接口命名规则: {模块}.{操作}
例如: customer.search, order.list, treat.page
"""
from typing import Dict, Any, Optional, List
from dataclasses import dataclass
@dataclass
class ApiDefinition:
"""接口定义"""
method: str # GET / POST
path: str # API 路径
description: str # 接口描述
module: str # 所属模块
params: Optional[List[str]] = None # URL 参数列表
body_required: bool = False # 是否需要请求体
# 睿美云开放接口注册表
RUIMEIYUN_APIS: Dict[str, Dict[str, Any]] = {
# ========================================
# 客户模块 (customer)
# ========================================
"customer.sync": {
"method": "POST",
"path": "/api/v1/tpos/customer/info-sync",
"description": "客户档案新增/编辑",
"module": "customer",
"body_required": True
},
"customer.search": {
"method": "GET",
"path": "/api/v1/tpos/customer/customer-search",
"description": "获取客户信息列表(支持姓名、电话、档案号模糊查询)",
"module": "customer",
"params": ["keyword", "createDateStart", "createDateEnd", "tenantId", "page", "size", "lastCustomerId"]
},
"customer.detail": {
"method": "GET",
"path": "/api/v1/tpos/customer/customer-by-id",
"description": "根据客户ID获取详细信息",
"module": "customer",
"params": ["customerId"]
},
"customer.rebate_list": {
"method": "POST",
"path": "/api/v1/tpos/customer/my-rebate-list",
"description": "获取客户返利列表",
"module": "customer",
"body_required": True
},
"customer.rebate_detail": {
"method": "GET",
"path": "/api/v1/tpos/customer/my-rebate-detail",
"description": "获取返利详情",
"module": "customer",
"params": ["rebateId"]
},
"customer.clue_list": {
"method": "POST",
"path": "/api/v1/tpos/customer/customer-clue-list",
"description": "获取客户线索列表",
"module": "customer",
"body_required": True
},
"customer.label_list": {
"method": "GET",
"path": "/api/v1/tpos/customer/customer-label-list",
"description": "获取客户标签列表",
"module": "customer",
"params": ["tenantId"]
},
"customer.gold_list": {
"method": "POST",
"path": "/api/v1/tpos/customer/customer-gold-list",
"description": "获取金卡客户列表",
"module": "customer",
"body_required": True
},
"customer.plan_list": {
"method": "GET",
"path": "/api/v1/tpos/customer/get-all-plan",
"description": "获取所有客户计划",
"module": "customer",
"params": ["tenantId"]
},
"customer.transfer_pool": {
"method": "POST",
"path": "/api/v1/tpos/customer/transfer-pool",
"description": "客户池转移",
"module": "customer",
"body_required": True
},
"customer.pool_info": {
"method": "GET",
"path": "/api/v1/tpos/customer/get-pool-info",
"description": "获取客户池信息",
"module": "customer",
"params": ["customerId"]
},
"customer.qw_info": {
"method": "GET",
"path": "/api/v1/tpos/customer/customer-qw-info",
"description": "获取客户企微信息",
"module": "customer",
"params": ["customerId"]
},
"customer.sign_search": {
"method": "POST",
"path": "/api/v1/tpos/customer/customer-sign-search",
"description": "客户签到搜索",
"module": "customer",
"body_required": True
},
# ========================================
# 门店模块 (tenant)
# ========================================
"tenant.list": {
"method": "GET",
"path": "/api/v1/tpos/common/tenantList",
"description": "获取门店信息列表",
"module": "tenant",
"params": []
},
# ========================================
# 回访模块 (visit)
# ========================================
"visit.type_list": {
"method": "GET",
"path": "/api/v1/tpos/visit/get-visit-type",
"description": "获取回访类型列表",
"module": "visit",
"params": ["tenantId"]
},
"visit.way_type_list": {
"method": "GET",
"path": "/api/v1/tpos/visit/get-visit-way-type",
"description": "获取回访方式类型列表",
"module": "visit",
"params": ["tenantId"]
},
"visit.page": {
"method": "POST",
"path": "/api/v1/tpos/visit/get-visit-page",
"description": "分页获取回访记录",
"module": "visit",
"body_required": True
},
"visit.create": {
"method": "POST",
"path": "/api/v1/tpos/visit/create-visit",
"description": "新增回访记录",
"module": "visit",
"body_required": True
},
"visit.template_type": {
"method": "GET",
"path": "/api/v1/tpos/visit/visit-template-type",
"description": "获取回访模板类型",
"module": "visit",
"params": ["tenantId"]
},
# ========================================
# 报备模块 (preparation)
# ========================================
"preparation.add": {
"method": "POST",
"path": "/api/v1/tpos/preparation/add",
"description": "新增报备",
"module": "preparation",
"body_required": True
},
"preparation.query": {
"method": "POST",
"path": "/api/v1/tpos/preparation/get-preparation",
"description": "查询报备",
"module": "preparation",
"body_required": True
},
# ========================================
# 员工模块 (user)
# ========================================
"user.page": {
"method": "GET",
"path": "/api/v1/tpos/user/get-page",
"description": "分页获取员工列表",
"module": "user",
"params": ["page", "size", "tenantId", "keyword"]
},
"user.dept_tree": {
"method": "GET",
"path": "/api/v1/tpos/basic/dept-tree",
"description": "获取部门树结构",
"module": "user",
"params": ["tenantId"]
},
"user.role_list": {
"method": "GET",
"path": "/api/v1/tpos/role/getRoleList",
"description": "获取角色列表",
"module": "user",
"params": ["tenantId"]
},
# ========================================
# 卡券模块 (coupon)
# ========================================
"coupon.customer_page": {
"method": "GET",
"path": "/api/v1/tpos/coupon/customer-coupon-page",
"description": "分页获取客户卡券",
"module": "coupon",
"params": ["customerId", "page", "size"]
},
"coupon.customer_list": {
"method": "GET",
"path": "/api/v1/tpos/coupon/customer-coupon-list",
"description": "获取客户卡券列表",
"module": "coupon",
"params": ["customerId"]
},
"coupon.use": {
"method": "POST",
"path": "/api/v1/tpos/coupon/use-coupon",
"description": "使用卡券",
"module": "coupon",
"body_required": True
},
"coupon.page": {
"method": "GET",
"path": "/api/v1/tpos/coupon/coupon-page",
"description": "分页获取卡券信息",
"module": "coupon",
"params": ["page", "size", "tenantId"]
},
"coupon.send": {
"method": "POST",
"path": "/api/v1/tpos/coupon/send-coupon",
"description": "发送卡券",
"module": "coupon",
"body_required": True
},
"coupon.gift": {
"method": "POST",
"path": "/api/v1/tpos/coupon/gift-coupon",
"description": "卡券赠送(小程序分享)",
"module": "coupon",
"body_required": True
},
"coupon.receive": {
"method": "POST",
"path": "/api/v1/tpos/coupon/receive-coupon",
"description": "领取卡券(小程序分享)",
"module": "coupon",
"body_required": True
},
# ========================================
# 营销模块 (marketing)
# ========================================
"marketing.appoint_card_page": {
"method": "GET",
"path": "/api/v1/tpos/marketing/appoint-card-page",
"description": "线上预约-名片管理",
"module": "marketing",
"params": ["page", "size", "tenantId"]
},
"marketing.graphic_message_list": {
"method": "GET",
"path": "/api/v1/tpos/marketing/graphic-message-list",
"description": "内容管理-图文消息",
"module": "marketing",
"params": ["tenantId"]
},
# ========================================
# 积分模块 (integral)
# ========================================
"integral.customer": {
"method": "GET",
"path": "/api/v1/tpos/integral/getCusIntegral",
"description": "获取客户积分",
"module": "integral",
"params": ["customerId"]
},
"integral.score_record_page": {
"method": "GET",
"path": "/api/v1/tpos/integral/score-record-page",
"description": "获取客户积分/成长值分页信息",
"module": "integral",
"params": ["customerId", "page", "size"]
},
"integral.growth_upgrade": {
"method": "POST",
"path": "/api/v1/tpos/integral/query-customer-growth-upgrade",
"description": "查询客户成长升级信息",
"module": "integral",
"body_required": True
},
# ========================================
# 订单模块 (order)
# ========================================
"order.billing_page": {
"method": "GET",
"path": "/api/v1/tpos/order/billing-page",
"description": "获取订单信息列表",
"module": "order",
"params": ["customerId", "page", "size", "startDate", "endDate"]
},
"order.payment_detail": {
"method": "GET",
"path": "/api/v1/tpos/order/payment-detail",
"description": "获取费用单详细信息",
"module": "order",
"params": ["billingId"]
},
"order.add_billing": {
"method": "POST",
"path": "/api/v1/tpos/order/add-billing",
"description": "开单",
"module": "order",
"body_required": True
},
"order.add_billing_review": {
"method": "POST",
"path": "/api/v1/tpos/order/add-billing-review",
"description": "开单审核",
"module": "order",
"body_required": True
},
"order.enable_billing": {
"method": "GET",
"path": "/api/v1/tpos/order/enable-billing",
"description": "可操作的订单项",
"module": "order",
"params": ["billingId"]
},
"order.refund": {
"method": "POST",
"path": "/api/v1/tpos/order/refund",
"description": "订单退款",
"module": "order",
"body_required": True
},
"order.gift_project": {
"method": "POST",
"path": "/api/v1/tpos/order/gift-project",
"description": "项目转赠(小程序分享)",
"module": "order",
"body_required": True
},
"order.receive_project": {
"method": "POST",
"path": "/api/v1/tpos/order/receive-project",
"description": "领取赠送项目(小程序分享)",
"module": "order",
"body_required": True
},
"order.equity_card_page": {
"method": "GET",
"path": "/api/v1/tpos/order/billing-equity-card-page",
"description": "获取客户权益卡列表",
"module": "order",
"params": ["customerId", "page", "size"]
},
"order.balance_recharge_page": {
"method": "GET",
"path": "/api/v1/tpos/order/balance-recharge-page",
"description": "储值充值记录",
"module": "order",
"params": ["customerId", "page", "size"]
},
"order.balance_deduction_page": {
"method": "GET",
"path": "/api/v1/tpos/order/balance-deduction-page",
"description": "储值抵扣记录",
"module": "order",
"params": ["customerId", "page", "size"]
},
"order.balance_refund_page": {
"method": "GET",
"path": "/api/v1/tpos/order/balance-refund-page",
"description": "储值退款记录",
"module": "order",
"params": ["customerId", "page", "size"]
},
"order.balance_transfer_page": {
"method": "GET",
"path": "/api/v1/tpos/order/balance-transfer-page",
"description": "储值转赠记录",
"module": "order",
"params": ["customerId", "page", "size"]
},
"order.integral_mall_page": {
"method": "GET",
"path": "/api/v1/tpos/order/integral-mall-exchange-page",
"description": "获取积分兑换订单信息列表",
"module": "order",
"params": ["customerId", "page", "size"]
},
"order.add_external": {
"method": "POST",
"path": "/api/v1/tpos/order/add-order-external",
"description": "外部订单创建",
"module": "order",
"body_required": True
},
"order.refund_external": {
"method": "POST",
"path": "/api/v1/tpos/order/refund-order-external",
"description": "外部订单退款",
"module": "order",
"body_required": True
},
"order.customer_billing_list": {
"method": "POST",
"path": "/api/v1/tpos/order/get-customer-billing-list",
"description": "获取客户订单列表",
"module": "order",
"body_required": True
},
"order.cashier_record_list": {
"method": "POST",
"path": "/api/v1/tpos/order/get-cashierRecord-list",
"description": "获取收银记录列表",
"module": "order",
"body_required": True
},
# ========================================
# 治疗模块 (treat)
# ========================================
"treat.untreated_page": {
"method": "GET",
"path": "/api/v1/tpos/treat/untreated-page",
"description": "查询客户未治疗记录",
"module": "treat",
"params": ["customerId", "page", "size"]
},
"treat.already_treated_page": {
"method": "GET",
"path": "/api/v1/tpos/treat/already-treated-page",
"description": "查询客户已治疗记录",
"module": "treat",
"params": ["customerId", "page", "size"]
},
"treat.page_review": {
"method": "GET",
"path": "/api/v1/tpos/treat/treated-page-review",
"description": "分页获取治疗数据",
"module": "treat",
"params": ["page", "size", "tenantId"]
},
"treat.operating_room_list": {
"method": "GET",
"path": "/api/v1/tpos/treat/get-operating-room-list",
"description": "获取治疗时查询的手术间信息",
"module": "treat",
"params": ["tenantId"]
},
"treat.begin_info": {
"method": "GET",
"path": "/api/v1/tpos/treat/get-begin-treat-info",
"description": "获取治疗中和已治疗的数据",
"module": "treat",
"params": ["treatId"]
},
"treat.deduct_verify": {
"method": "POST",
"path": "/api/v1/tpos/treat/treat-deduct-verify",
"description": "进行核销和划扣",
"module": "treat",
"body_required": True
},
"treat.cancel_deduct": {
"method": "POST",
"path": "/api/v1/tpos/treat/cancel-deduct",
"description": "取消划扣",
"module": "treat",
"body_required": True
},
"treat.cancel_verify": {
"method": "POST",
"path": "/api/v1/tpos/treat/cancel-verify",
"description": "取消核销",
"module": "treat",
"body_required": True
},
"treat.deduct_verify_detail": {
"method": "GET",
"path": "/api/v1/tpos/treat/get-treated-deduct-and-verify-detail",
"description": "已治疗的核销和划扣详情信息",
"module": "treat",
"params": ["treatId"]
},
"treat.roles": {
"method": "GET",
"path": "/api/v1/tpos/treat/get-treatment-roles",
"description": "获取所有的治疗岗位列表",
"module": "treat",
"params": ["tenantId"]
},
"treat.scrm_list": {
"method": "POST",
"path": "/api/v1/tpos/treat/scrmTreatList",
"description": "小程序-我的治疗(新版)",
"module": "treat",
"body_required": True
},
# ========================================
# 照片模块 (photo)
# ========================================
"photo.add": {
"method": "POST",
"path": "/api/v1/tpos/common/addPhoto",
"description": "外部七牛照片转存至睿美云",
"module": "photo",
"body_required": True
},
"photo.add_open": {
"method": "POST",
"path": "/api/v1/tpos/common/addPhotoOpen",
"description": "外部照片路径转存至睿美云",
"module": "photo",
"body_required": True
},
"photo.upload": {
"method": "POST",
"path": "/api/v1/tpos/common/upload_customer_photo",
"description": "上传照片到睿美云",
"module": "photo",
"body_required": True
},
"photo.page": {
"method": "GET",
"path": "/api/v1/tpos/common/photoPage",
"description": "通过客户id分页查询照片信息",
"module": "photo",
"params": ["customerId", "page", "size"]
},
"photo.skin_update": {
"method": "POST",
"path": "/api/v1/tpos/skin_image/update_skin_file",
"description": "皮肤检测类图片上传",
"module": "photo",
"body_required": True
},
# ========================================
# 基础数据模块 (basic)
# ========================================
"basic.project_page": {
"method": "GET",
"path": "/api/v1/tpos/basic/project-page",
"description": "分页获取项目列表",
"module": "basic",
"params": ["page", "size", "tenantId", "keyword"]
},
"basic.project_type_tree": {
"method": "GET",
"path": "/api/v1/tpos/basic/project-type-tree",
"description": "获取项目分类树",
"module": "basic",
"params": ["tenantId"]
},
"basic.project_detail": {
"method": "GET",
"path": "/api/v1/tpos/basic/project-detail",
"description": "获取项目详情",
"module": "basic",
"params": ["projectId"]
},
"basic.package_page": {
"method": "GET",
"path": "/api/v1/tpos/basic/package-page",
"description": "分页获取套餐列表",
"module": "basic",
"params": ["page", "size", "tenantId"]
},
"basic.package_type_tree": {
"method": "GET",
"path": "/api/v1/tpos/basic/package-type-tree",
"description": "获取套餐分类树",
"module": "basic",
"params": ["tenantId"]
},
"basic.package_detail": {
"method": "GET",
"path": "/api/v1/tpos/basic/package-detail",
"description": "获取套餐详情",
"module": "basic",
"params": ["packageId"]
},
"basic.annual_card_page": {
"method": "GET",
"path": "/api/v1/tpos/basic/annual-card-page",
"description": "分页获取年卡列表",
"module": "basic",
"params": ["page", "size", "tenantId"]
},
"basic.annual_card_type_tree": {
"method": "GET",
"path": "/api/v1/tpos/basic/annual-card-type-tree",
"description": "获取年卡分类树",
"module": "basic",
"params": ["tenantId"]
},
"basic.annual_card_detail": {
"method": "GET",
"path": "/api/v1/tpos/basic/annual-card-detail",
"description": "获取年卡详情",
"module": "basic",
"params": ["cardId"]
},
"basic.time_card_page": {
"method": "GET",
"path": "/api/v1/tpos/basic/time-card-page",
"description": "分页获取次卡列表",
"module": "basic",
"params": ["page", "size", "tenantId"]
},
"basic.time_card_type_tree": {
"method": "GET",
"path": "/api/v1/tpos/basic/time-card-type-tree",
"description": "获取次卡分类树",
"module": "basic",
"params": ["tenantId"]
},
"basic.time_card_detail": {
"method": "GET",
"path": "/api/v1/tpos/basic/time-card-detail",
"description": "获取次卡详情",
"module": "basic",
"params": ["cardId"]
},
# ========================================
# 预约模块 (cusbespeak)
# ========================================
"appointment.add": {
"method": "POST",
"path": "/api/v1/tpos/cusbespeak/add",
"description": "新增预约",
"module": "appointment",
"body_required": True
},
"appointment.update": {
"method": "POST",
"path": "/api/v1/tpos/cusbespeak/update",
"description": "修改预约",
"module": "appointment",
"body_required": True
},
"appointment.confirm": {
"method": "POST",
"path": "/api/v1/tpos/cusbespeak/confirm",
"description": "确认预约",
"module": "appointment",
"body_required": True
},
"appointment.cancel": {
"method": "POST",
"path": "/api/v1/tpos/cusbespeak/cancel",
"description": "取消预约",
"module": "appointment",
"body_required": True
},
"appointment.page": {
"method": "POST",
"path": "/api/v1/tpos/cusbespeak/page",
"description": "预约分页查询",
"module": "appointment",
"body_required": True
},
"appointment.doctor_list": {
"method": "GET",
"path": "/api/v1/tpos/cusbespeak/doctor-list",
"description": "获取可选择的预约医生",
"module": "appointment",
"params": ["tenantId"]
},
"appointment.schedule": {
"method": "GET",
"path": "/api/v1/tpos/cusbespeak/schedule",
"description": "查询预约专家排班",
"module": "appointment",
"params": ["doctorId", "date"]
},
# ========================================
# 渠道模块 (channel)
# ========================================
"channel.type_select": {
"method": "GET",
"path": "/api/v1/tpos/channel/type-select",
"description": "整合渠道类型选择(建档,报备)",
"module": "channel",
"params": ["tenantId"]
},
"channel.list_by_type": {
"method": "GET",
"path": "/api/v1/tpos/channel/list-by-type",
"description": "通过渠道类型获取渠道列表",
"module": "channel",
"params": ["typeId", "tenantId"]
},
"channel.info": {
"method": "GET",
"path": "/api/v1/tpos/channel/info",
"description": "查询渠道信息",
"module": "channel",
"params": ["channelId"]
},
"channel.media_info": {
"method": "GET",
"path": "/api/v1/tpos/channel/media-info",
"description": "查询运营媒体信息",
"module": "channel",
"params": ["mediaId"]
},
# ========================================
# 接待模块 (reception)
# ========================================
"reception.triage_list": {
"method": "GET",
"path": "/api/v1/tpos/reception/triage-list",
"description": "可用的接待分诊人列表",
"module": "reception",
"params": ["tenantId"]
},
"reception.add": {
"method": "POST",
"path": "/api/v1/tpos/reception/add",
"description": "新增接待",
"module": "reception",
"body_required": True
},
"reception.query": {
"method": "GET",
"path": "/api/v1/tpos/reception/query",
"description": "查询客户接待信息",
"module": "reception",
"params": ["customerId"]
},
"reception.sign_init": {
"method": "GET",
"path": "/api/v1/tpos/reception/sign-init",
"description": "客户扫码签到初始化数据(小程序)",
"module": "reception",
"params": ["tenantId"]
},
"reception.sign": {
"method": "POST",
"path": "/api/v1/tpos/reception/sign",
"description": "客户扫码签到(小程序)",
"module": "reception",
"body_required": True
},
# ========================================
# 咨询模块 (consult)
# ========================================
"consult.add": {
"method": "POST",
"path": "/api/v1/tpos/consult/add",
"description": "新增咨询",
"module": "consult",
"body_required": True
},
"consult.update": {
"method": "POST",
"path": "/api/v1/tpos/consult/update",
"description": "修改咨询",
"module": "consult",
"body_required": True
},
# ========================================
# 病历模块 (medical_record)
# ========================================
"medical_record.add": {
"method": "POST",
"path": "/api/v1/tpos/medical-record/add",
"description": "新增病历",
"module": "medical_record",
"body_required": True
},
"medical_record.update": {
"method": "POST",
"path": "/api/v1/tpos/medical-record/update",
"description": "修改病历",
"module": "medical_record",
"body_required": True
},
"medical_record.delete": {
"method": "POST",
"path": "/api/v1/tpos/medical-record/delete",
"description": "删除病历",
"module": "medical_record",
"body_required": True
},
}
def get_api_definition(api_name: str) -> Optional[Dict[str, Any]]:
"""
获取接口定义
Args:
api_name: 接口名称,如 customer.search
Returns:
接口定义字典,不存在则返回 None
"""
return RUIMEIYUN_APIS.get(api_name)
def get_api_list_by_module(module: str) -> List[Dict[str, Any]]:
"""
按模块获取接口列表
Args:
module: 模块名称,如 customer, order
Returns:
该模块下的接口列表
"""
result = []
for name, definition in RUIMEIYUN_APIS.items():
if definition.get("module") == module:
result.append({"name": name, **definition})
return result
def get_all_modules() -> List[str]:
"""获取所有模块名称"""
modules = set()
for definition in RUIMEIYUN_APIS.values():
if "module" in definition:
modules.add(definition["module"])
return sorted(list(modules))
def get_api_summary() -> Dict[str, int]:
"""获取接口统计"""
summary = {}
for definition in RUIMEIYUN_APIS.values():
module = definition.get("module", "unknown")
summary[module] = summary.get(module, 0) + 1
return summary

View File

@@ -0,0 +1,609 @@
"""定时任务调度服务"""
import json
import httpx
import asyncio
from datetime import datetime
from typing import Optional
from apscheduler.schedulers.asyncio import AsyncIOScheduler
from apscheduler.triggers.cron import CronTrigger
from sqlalchemy.orm import Session
from ..database import SessionLocal
from ..models.scheduled_task import ScheduledTask, TaskLog
from ..models.notification_channel import TaskNotifyChannel
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.is_enabled == True).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 = task.time_points if isinstance(task.time_points, list) else 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.is_enabled:
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 = ''
result = None
try:
# 解析输入参数
params = {}
if task.input_params:
params = task.input_params if isinstance(task.input_params, dict) else {}
if task.execution_type == 'webhook':
success, output, error = await self._execute_webhook(task)
else:
success, output, error, result = await self._execute_script(db, task, trace_id, params)
# 如果脚本执行成功且有返回内容,发送通知
if success and result and result.get('content'):
await self._send_notifications(db, task, result)
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:
body = {}
if task.input_params:
body = task.input_params if isinstance(task.input_params, dict) else {}
async with httpx.AsyncClient(timeout=30) as client:
response = await client.post(task.webhook_url, 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, '', '脚本内容为空', None
executor = ScriptExecutor(db)
success, output, error, result = executor.execute(
script_content=task.script_content,
task_id=task.id,
tenant_id=task.tenant_id,
trace_id=trace_id,
params=params,
timeout=300 # 默认超时
)
return success, output, error, result
async def _send_notifications(self, db: Session, task: ScheduledTask, result: dict):
"""发送通知到配置的渠道
result 格式:
- 简单格式: {'content': '内容', 'title': '标题'}
- 完整格式: {'msg_type': 'actionCard', 'title': '...', 'content': '...', 'buttons': [...]}
支持的 msg_type:
- text: 纯文本
- markdown: Markdown格式默认
- link: 链接消息
- actionCard: 交互卡片(带按钮)
- feedCard: 信息流卡片
- news: 图文消息(企微)
- template_card: 模板卡片(企微)
"""
content = result.get('content', '')
title = result.get('title', task.task_name)
if not content and result.get('msg_type') not in ('feedCard', 'news', 'template_card'):
return
# 获取通知渠道配置
channel_ids = task.notify_channels
if isinstance(channel_ids, str):
try:
channel_ids = json.loads(channel_ids)
except:
channel_ids = []
if not channel_ids:
channel_ids = []
# 发送到通知渠道
for channel_id in channel_ids:
try:
channel = db.query(TaskNotifyChannel).filter(
TaskNotifyChannel.id == channel_id,
TaskNotifyChannel.is_enabled == True
).first()
if not channel:
continue
await self._send_to_channel(channel, result)
except Exception as e:
print(f"发送通知到渠道 {channel_id} 失败: {e}")
# 发送到企微应用
if task.notify_wecom_app_id:
try:
await self._send_to_wecom_app(db, task.notify_wecom_app_id, result, task.tenant_id)
except Exception as e:
print(f"发送企微应用消息失败: {e}")
async def _send_to_channel(self, channel: TaskNotifyChannel, result: dict):
"""发送消息到通知渠道
钉钉支持: text, markdown, link, actionCard, feedCard
企微支持: text, markdown, image, news, template_card
"""
import time
import hmac
import hashlib
import base64
import urllib.parse
url = channel.webhook_url
msg_type = result.get('msg_type', 'markdown')
title = result.get('title', '通知')
content = result.get('content', '')
if channel.channel_type == 'dingtalk_bot':
# 钉钉加签
if channel.sign_secret:
timestamp = str(round(time.time() * 1000))
string_to_sign = f'{timestamp}\n{channel.sign_secret}'
hmac_code = hmac.new(
channel.sign_secret.encode('utf-8'),
string_to_sign.encode('utf-8'),
digestmod=hashlib.sha256
).digest()
sign = urllib.parse.quote_plus(base64.b64encode(hmac_code))
if '?' in url:
url = f"{url}&timestamp={timestamp}&sign={sign}"
else:
url = f"{url}?timestamp={timestamp}&sign={sign}"
payload = self._build_dingtalk_payload(msg_type, title, content, result)
else: # wecom_bot
payload = self._build_wecom_payload(msg_type, title, content, result)
async with httpx.AsyncClient(timeout=10) as client:
response = await client.post(url, json=payload)
resp = response.json()
if resp.get('errcode') != 0:
print(f"通知发送失败: {resp}")
def _build_dingtalk_payload(self, msg_type: str, title: str, content: str, result: dict) -> dict:
"""构建钉钉消息体
支持类型:
- text: 纯文本
- markdown: Markdown
- link: 链接消息
- actionCard: 交互卡片(整体跳转/独立跳转)
- feedCard: 信息流卡片
"""
if msg_type == 'text':
return {
"msgtype": "text",
"text": {"content": content},
"at": result.get('at', {})
}
elif msg_type == 'link':
return {
"msgtype": "link",
"link": {
"title": title,
"text": content,
"messageUrl": result.get('url', ''),
"picUrl": result.get('pic_url', '')
}
}
elif msg_type == 'actionCard':
buttons = result.get('buttons', [])
card = {
"title": title,
"text": content,
"btnOrientation": result.get('btn_orientation', '0') # 0-竖向 1-横向
}
if len(buttons) == 1:
# 整体跳转
card["singleTitle"] = buttons[0].get('title', '查看详情')
card["singleURL"] = buttons[0].get('url', '')
elif len(buttons) > 1:
# 独立跳转
card["btns"] = [
{"title": btn.get('title', ''), "actionURL": btn.get('url', '')}
for btn in buttons
]
return {"msgtype": "actionCard", "actionCard": card}
elif msg_type == 'feedCard':
links = result.get('links', [])
return {
"msgtype": "feedCard",
"feedCard": {
"links": [
{
"title": link.get('title', ''),
"messageURL": link.get('url', ''),
"picURL": link.get('pic_url', '')
}
for link in links
]
}
}
else: # markdown默认
return {
"msgtype": "markdown",
"markdown": {"title": title, "text": content},
"at": result.get('at', {})
}
def _build_wecom_payload(self, msg_type: str, title: str, content: str, result: dict) -> dict:
"""构建企微消息体
支持类型:
- text: 纯文本
- markdown: Markdown
- image: 图片
- news: 图文消息
- template_card: 模板卡片
"""
if msg_type == 'text':
payload = {
"msgtype": "text",
"text": {"content": content}
}
if result.get('mentioned_list'):
payload["text"]["mentioned_list"] = result.get('mentioned_list')
if result.get('mentioned_mobile_list'):
payload["text"]["mentioned_mobile_list"] = result.get('mentioned_mobile_list')
return payload
elif msg_type == 'image':
return {
"msgtype": "image",
"image": {
"base64": result.get('image_base64', ''),
"md5": result.get('image_md5', '')
}
}
elif msg_type == 'news':
articles = result.get('articles', [])
if not articles and content:
articles = [{
"title": title,
"description": content,
"url": result.get('url', ''),
"picurl": result.get('pic_url', '')
}]
return {
"msgtype": "news",
"news": {"articles": articles}
}
elif msg_type == 'template_card':
card_type = result.get('card_type', 'text_notice')
if card_type == 'text_notice':
# 文本通知卡片
return {
"msgtype": "template_card",
"template_card": {
"card_type": "text_notice",
"main_title": {"title": title, "desc": result.get('desc', '')},
"sub_title_text": content,
"horizontal_content_list": result.get('horizontal_list', []),
"jump_list": result.get('jump_list', []),
"card_action": result.get('card_action', {"type": 1, "url": ""})
}
}
elif card_type == 'news_notice':
# 图文展示卡片
return {
"msgtype": "template_card",
"template_card": {
"card_type": "news_notice",
"main_title": {"title": title, "desc": result.get('desc', '')},
"card_image": {"url": result.get('image_url', ''), "aspect_ratio": result.get('aspect_ratio', 1.3)},
"vertical_content_list": result.get('vertical_list', []),
"horizontal_content_list": result.get('horizontal_list', []),
"jump_list": result.get('jump_list', []),
"card_action": result.get('card_action', {"type": 1, "url": ""})
}
}
elif card_type == 'button_interaction':
# 按钮交互卡片
return {
"msgtype": "template_card",
"template_card": {
"card_type": "button_interaction",
"main_title": {"title": title, "desc": result.get('desc', '')},
"sub_title_text": content,
"horizontal_content_list": result.get('horizontal_list', []),
"button_list": result.get('buttons', []),
"card_action": result.get('card_action', {"type": 1, "url": ""})
}
}
else: # markdown默认
return {
"msgtype": "markdown",
"markdown": {"content": f"**{title}**\n\n{content}"}
}
async def _send_to_wecom_app(self, db: Session, app_id: int, result: dict, tenant_id: str):
"""发送消息到企微应用"""
from ..models.tenant_wechat_app import TenantWechatApp
app = db.query(TenantWechatApp).filter(TenantWechatApp.id == app_id).first()
if not app:
return
# 获取 access_token
access_token = await self._get_wecom_access_token(app.corp_id, app.app_secret)
if not access_token:
return
title = result.get('title', '通知')
content = result.get('content', '')
# 发送消息
url = f"https://qyapi.weixin.qq.com/cgi-bin/message/send?access_token={access_token}"
payload = {
"touser": result.get('touser', '@all'),
"msgtype": "markdown",
"agentid": app.agent_id,
"markdown": {
"content": f"**{title}**\n\n{content}"
}
}
async with httpx.AsyncClient(timeout=10) as client:
response = await client.post(url, json=payload)
result = response.json()
if result.get('errcode') != 0:
print(f"企微应用消息发送失败: {result}")
async def _get_wecom_access_token(self, corp_id: str, app_secret: str) -> Optional[str]:
"""获取企微 access_token"""
url = f"https://qyapi.weixin.qq.com/cgi-bin/gettoken?corpid={corp_id}&corpsecret={app_secret}"
async with httpx.AsyncClient(timeout=10) as client:
response = await client.get(url)
result = response.json()
if result.get('errcode') == 0:
return result.get('access_token')
else:
print(f"获取企微 access_token 失败: {result}")
return None
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": "任务不存在"}
success, output, error = await self._execute_task_once(db, task)
return {
"success": success,
"output": output,
"error": error
}
finally:
db.close()
# 全局调度器实例
scheduler_service = SchedulerService()

View File

@@ -0,0 +1,285 @@
"""脚本执行器 - 安全执行Python脚本"""
import sys
import traceback
from io import StringIO
from typing import Any, Dict, Optional, Tuple
from datetime import datetime
from sqlalchemy.orm import Session
from .script_sdk import ScriptSDK
# 禁止导入的模块
FORBIDDEN_MODULES = {
'os', 'subprocess', 'shutil', 'pathlib',
'socket', 'ftplib', 'telnetlib', 'smtplib',
'pickle', 'shelve', 'marshal',
'ctypes', 'multiprocessing',
'__builtins__', 'builtins',
'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 ScriptExecutor:
"""脚本执行器"""
def __init__(self, db: Session):
self.db = db
def execute(
self,
script_content: str,
task_id: int,
tenant_id: Optional[str] = None,
trace_id: Optional[str] = None,
params: Optional[Dict[str, Any]] = None,
timeout: int = 300
) -> Tuple[bool, str, str, Optional[Dict]]:
"""执行脚本
Args:
script_content: Python脚本内容
task_id: 任务ID
tenant_id: 租户ID
trace_id: 追踪ID
params: 输入参数
timeout: 超时秒数
Returns:
(success, output, error, result)
result: 脚本返回值 {'content': '...', 'title': '...'}
"""
# 创建SDK实例
sdk = ScriptSDK(
db=self.db,
task_id=task_id,
tenant_id=tenant_id,
trace_id=trace_id,
params=params or {}
)
# 检查脚本安全性
check_result = self._check_script_safety(script_content)
if check_result:
return False, '', f"脚本安全检查失败: {check_result}", None
# 准备执行环境
safe_globals = self._create_safe_globals(sdk)
# 捕获输出
old_stdout = sys.stdout
old_stderr = sys.stderr
stdout_capture = StringIO()
stderr_capture = StringIO()
try:
sys.stdout = stdout_capture
sys.stderr = stderr_capture
# 编译并执行脚本
compiled = compile(script_content, '<script>', 'exec')
exec(compiled, safe_globals)
# 获取输出
stdout_output = stdout_capture.getvalue()
sdk_output = sdk.get_output()
# 合并输出
output = '\n'.join(filter(None, [sdk_output, stdout_output]))
# 获取脚本返回值(通过 __result__ 变量)
result = safe_globals.get('__result__')
if result is None and 'result' in safe_globals:
result = safe_globals.get('result')
# 如果返回的是字符串,包装成字典
if isinstance(result, str):
result = {'content': result}
elif result is not None and not isinstance(result, dict):
result = {'content': str(result)}
return True, output, '', result
except Exception as e:
error_msg = f"{type(e).__name__}: {str(e)}\n{traceback.format_exc()}"
return False, sdk.get_output(), error_msg, None
finally:
sys.stdout = old_stdout
sys.stderr = old_stderr
def _check_script_safety(self, script_content: str) -> Optional[str]:
"""检查脚本安全性
Returns:
错误消息如果安全则返回None
"""
# 检查危险导入
import_patterns = [
'import os', 'from os',
'import subprocess', 'from subprocess',
'import shutil', 'from shutil',
'import socket', 'from socket',
'__import__',
'eval(', 'exec(',
'compile(',
'open(', # 禁止文件操作
]
script_lower = script_content.lower()
for pattern in import_patterns:
if pattern.lower() in script_lower:
return f"禁止使用: {pattern}"
return None
def _create_safe_globals(self, sdk: ScriptSDK) -> Dict[str, Any]:
"""创建安全的执行环境"""
import json
import re
import math
import random
import hashlib
import base64
import time
import collections
from datetime import datetime, date, timedelta
from urllib.parse import urlencode, quote, unquote
# 允许导入的模块白名单
ALLOWED_MODULES = {
'json': json,
're': re,
'math': math,
'random': random,
'hashlib': hashlib,
'base64': base64,
'time': time,
'datetime': __import__('datetime'),
'collections': collections,
}
def safe_import(name, globals=None, locals=None, fromlist=(), level=0):
"""受限的 import 函数"""
if name in ALLOWED_MODULES:
return ALLOWED_MODULES[name]
raise ImportError(f"不允许导入模块: {name}。已内置可用: {', '.join(ALLOWED_MODULES.keys())}")
# 安全的内置函数
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__}
# 添加受限的 __import__
safe_builtins['__import__'] = safe_import
# 添加常用异常
safe_builtins['Exception'] = Exception
safe_builtins['ValueError'] = ValueError
safe_builtins['TypeError'] = TypeError
safe_builtins['KeyError'] = KeyError
safe_builtins['IndexError'] = IndexError
safe_builtins['ImportError'] = ImportError
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,
'time': time,
'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,
"result": dict
}
"""
start_time = datetime.now()
success, output, error, result = self.execute(
script_content=script_content,
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,
"result": result
}

View File

@@ -0,0 +1,479 @@
"""脚本执行SDK - 为Python脚本提供内置功能"""
import json
import os
import httpx
from datetime import datetime
from typing import Any, Dict, List, Optional
from sqlalchemy.orm import Session
class ScriptSDK:
"""脚本SDK - 提供AI、通知、数据库、HTTP、变量存储等功能"""
def __init__(
self,
db: Session,
task_id: int,
tenant_id: Optional[str] = None,
trace_id: Optional[str] = None,
params: Optional[Dict[str, Any]] = None
):
self.db = db
self.task_id = task_id
self.tenant_id = tenant_id
self.trace_id = trace_id
self.params = params or {}
self._logs: List[Dict] = []
self._output: List[str] = []
self._tenants_cache: Dict = {}
# AI 配置
self._ai_base_url = os.getenv('OPENAI_BASE_URL', 'https://api.4sapi.net/v1')
self._ai_api_key = os.getenv('OPENAI_API_KEY', 'sk-9yMCXjRGANbacz20kJY8doSNy6Rf446aYwmgGIuIXQ7DAyBw')
self._ai_model = os.getenv('OPENAI_MODEL', 'gemini-2.5-flash')
# ==================== 参数获取 ====================
def get_param(self, key: str, default: Any = None) -> Any:
"""获取任务参数
Args:
key: 参数名
default: 默认值
Returns:
参数值
"""
return self.params.get(key, default)
def get_params(self) -> Dict[str, Any]:
"""获取所有任务参数
Returns:
所有参数字典
"""
return self.params.copy()
# ==================== 日志 ====================
def log(self, message: str, level: str = 'INFO') -> None:
"""记录日志
Args:
message: 日志内容
level: 日志级别 (INFO, WARN, ERROR)
"""
log_entry = {
'time': datetime.now().isoformat(),
'level': level.upper(),
'message': message
}
self._logs.append(log_entry)
self._output.append(f"[{level.upper()}] {message}")
def print(self, *args, **kwargs) -> None:
"""打印输出兼容print"""
message = ' '.join(str(arg) for arg in args)
self._output.append(message)
def get_logs(self) -> List[Dict]:
"""获取所有日志"""
return self._logs
def get_output(self) -> str:
"""获取所有输出"""
return '\n'.join(self._output)
# ==================== AI 调用 ====================
def ai(
self,
prompt: str,
system: Optional[str] = None,
model: Optional[str] = None,
temperature: float = 0.7,
max_tokens: int = 2000
) -> str:
"""调用AI模型
Args:
prompt: 用户提示词
system: 系统提示词
model: 模型名称默认gemini-2.5-flash
temperature: 温度参数
max_tokens: 最大token数
Returns:
AI响应内容
"""
model = model or self._ai_model
messages = []
if system:
messages.append({"role": "system", "content": system})
messages.append({"role": "user", "content": prompt})
try:
with httpx.Client(timeout=60) as client:
response = client.post(
f"{self._ai_base_url}/chat/completions",
headers={
"Authorization": f"Bearer {self._ai_api_key}",
"Content-Type": "application/json"
},
json={
"model": model,
"messages": messages,
"temperature": temperature,
"max_tokens": max_tokens
}
)
response.raise_for_status()
data = response.json()
content = data['choices'][0]['message']['content']
self.log(f"AI调用成功: {len(content)} 字符")
return content
except Exception as e:
self.log(f"AI调用失败: {str(e)}", 'ERROR')
raise
# ==================== 通知 ====================
def dingtalk(self, webhook: str, content: str, title: Optional[str] = None, at_all: bool = False) -> bool:
"""发送钉钉消息
Args:
webhook: 钉钉机器人webhook地址
content: 消息内容支持Markdown
title: 消息标题
at_all: 是否@所有人
Returns:
是否发送成功
"""
try:
payload = {
"msgtype": "markdown",
"markdown": {
"title": title or "通知",
"text": content + ("\n@所有人" if at_all else "")
},
"at": {"isAtAll": at_all}
}
with httpx.Client(timeout=10) as client:
response = client.post(webhook, json=payload)
response.raise_for_status()
result = response.json()
success = result.get('errcode') == 0
self.log(f"钉钉消息发送{'成功' if success else '失败'}")
return success
except Exception as e:
self.log(f"钉钉消息发送失败: {str(e)}", 'ERROR')
return False
def wecom(self, webhook: str, content: str, msg_type: str = 'markdown') -> bool:
"""发送企业微信消息
Args:
webhook: 企微机器人webhook地址
content: 消息内容
msg_type: 消息类型 (text, markdown)
Returns:
是否发送成功
"""
try:
if msg_type == 'markdown':
payload = {
"msgtype": "markdown",
"markdown": {"content": content}
}
else:
payload = {
"msgtype": "text",
"text": {"content": content}
}
with httpx.Client(timeout=10) as client:
response = client.post(webhook, json=payload)
response.raise_for_status()
result = response.json()
success = result.get('errcode') == 0
self.log(f"企微消息发送{'成功' if success else '失败'}")
return success
except Exception as e:
self.log(f"企微消息发送失败: {str(e)}", 'ERROR')
return False
# ==================== HTTP 请求 ====================
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": "..."}
"""
try:
with httpx.Client(timeout=timeout) as client:
response = client.get(url, headers=headers, params=params)
return {
"status": response.status_code,
"data": response.json() if response.headers.get('content-type', '').startswith('application/json') else None,
"text": response.text
}
except Exception as e:
self.log(f"HTTP GET 失败: {str(e)}", 'ERROR')
raise
def http_post(self, url: str, data: Any = None, headers: Optional[Dict] = None, timeout: int = 30) -> Dict:
"""发起HTTP POST请求
Returns:
{"status": 200, "data": ..., "text": "..."}
"""
try:
with httpx.Client(timeout=timeout) as client:
response = client.post(url, json=data, headers=headers)
return {
"status": response.status_code,
"data": response.json() if response.headers.get('content-type', '').startswith('application/json') else None,
"text": response.text
}
except Exception as e:
self.log(f"HTTP POST 失败: {str(e)}", 'ERROR')
raise
# ==================== 数据库查询(只读)====================
def db_query(self, sql: str, params: Optional[Dict] = None) -> List[Dict]:
"""执行只读SQL查询
Args:
sql: SQL语句必须是SELECT
params: 参数字典
Returns:
查询结果列表
"""
sql_upper = sql.strip().upper()
if not sql_upper.startswith('SELECT'):
raise ValueError("只允许执行SELECT查询")
# 禁止危险操作
forbidden = ['INSERT', 'UPDATE', 'DELETE', 'DROP', 'TRUNCATE', 'ALTER', 'CREATE']
for word in forbidden:
if word in sql_upper:
raise ValueError(f"禁止执行 {word} 操作")
try:
from sqlalchemy import text
result = self.db.execute(text(sql), params or {})
columns = result.keys()
rows = [dict(zip(columns, row)) for row in result.fetchall()]
self.log(f"SQL查询返回 {len(rows)} 条记录")
return rows
except Exception as e:
self.log(f"SQL查询失败: {str(e)}", 'ERROR')
raise
# ==================== 变量存储 ====================
def get_var(self, key: str, default: Any = None) -> Any:
"""获取持久化变量
Args:
key: 变量名
default: 默认值
Returns:
变量值
"""
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:
return json.loads(var.var_value)
except:
return var.var_value
return default
def set_var(self, key: str, value: Any) -> None:
"""设置持久化变量
Args:
key: 变量名
value: 变量值会JSON序列化
"""
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()
value_json = json.dumps(value, ensure_ascii=False)
if var:
var.var_value = value_json
else:
var = ScriptVar(
task_id=self.task_id,
tenant_id=self.tenant_id,
var_key=key,
var_value=value_json
)
self.db.add(var)
self.db.commit()
self.log(f"变量 {key} 已保存")
def del_var(self, key: str) -> bool:
"""删除持久化变量"""
from ..models.scheduled_task import ScriptVar
result = self.db.query(ScriptVar).filter(
ScriptVar.task_id == self.task_id,
ScriptVar.tenant_id == self.tenant_id,
ScriptVar.var_key == key
).delete()
self.db.commit()
return result > 0
# ==================== 租户配置 ====================
def get_tenants(self, app_code: Optional[str] = None) -> List[Dict]:
"""获取租户列表
Args:
app_code: 可选,按应用代码筛选
Returns:
租户列表 [{"tenant_id": ..., "tenant_name": ...}, ...]
"""
from ..models.tenant import Tenant
from ..models.tenant_app import TenantApp
if app_code:
# 获取订阅了该应用的租户
tenant_ids = self.db.query(TenantApp.tenant_id).filter(
TenantApp.app_code == app_code,
TenantApp.status == 1
).all()
tenant_ids = [t[0] for t in tenant_ids]
tenants = self.db.query(Tenant).filter(
Tenant.code.in_(tenant_ids),
Tenant.status == 'active'
).all()
else:
tenants = self.db.query(Tenant).filter(Tenant.status == 'active').all()
return [{"tenant_id": t.code, "tenant_name": t.name} for t in tenants]
def get_tenant_config(self, tenant_id: str, app_code: str, key: Optional[str] = None) -> Any:
"""获取租户的应用配置
Args:
tenant_id: 租户ID
app_code: 应用代码
key: 配置键(可选,不提供则返回所有配置)
Returns:
配置值或配置字典
"""
from ..models.tenant_app import TenantApp
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 {}
# 解析 custom_configs
configs = {}
if hasattr(tenant_app, 'custom_configs') and tenant_app.custom_configs:
try:
configs = json.loads(tenant_app.custom_configs) if isinstance(tenant_app.custom_configs, str) else tenant_app.custom_configs
except:
pass
if key:
return configs.get(key)
return configs
def get_all_tenant_configs(self, app_code: str) -> List[Dict]:
"""获取所有租户的应用配置
Args:
app_code: 应用代码
Returns:
[{"tenant_id": ..., "tenant_name": ..., "configs": {...}}, ...]
"""
from ..models.tenant import Tenant
from ..models.tenant_app import TenantApp
tenant_apps = self.db.query(TenantApp).filter(
TenantApp.app_code == app_code,
TenantApp.status == 1
).all()
result = []
for ta in tenant_apps:
tenant = self.db.query(Tenant).filter(Tenant.code == ta.tenant_id).first()
configs = {}
if hasattr(ta, 'custom_configs') and ta.custom_configs:
try:
configs = json.loads(ta.custom_configs) if isinstance(ta.custom_configs, str) else ta.custom_configs
except:
pass
result.append({
"tenant_id": ta.tenant_id,
"tenant_name": tenant.name if tenant else ta.tenant_id,
"configs": configs
})
return result
# ==================== 密钥管理 ====================
def get_secret(self, key: str) -> Optional[str]:
"""获取密钥(优先租户级,其次全局)
Args:
key: 密钥名
Returns:
密钥值
"""
from ..models.scheduled_task import Secret
# 先查租户级
if self.tenant_id:
secret = self.db.query(Secret).filter(
Secret.tenant_id == self.tenant_id,
Secret.secret_key == key
).first()
if secret:
return secret.secret_value
# 再查全局
secret = self.db.query(Secret).filter(
Secret.tenant_id.is_(None),
Secret.secret_key == key
).first()
return secret.secret_value if secret else None

View File

@@ -1,14 +1,15 @@
fastapi>=0.109.0
uvicorn>=0.27.0
sqlalchemy>=2.0.0
pymysql>=1.1.0
pydantic>=2.0.0
pydantic-settings>=2.0.0
cryptography>=42.0.0
python-jose[cryptography]>=3.3.0
passlib[bcrypt]>=1.7.4
bcrypt>=4.0.0
python-multipart>=0.0.6
httpx>=0.26.0
redis>=5.0.0
openpyxl>=3.1.0
fastapi>=0.109.0
uvicorn>=0.27.0
sqlalchemy>=2.0.0
pymysql>=1.1.0
pydantic>=2.0.0
pydantic-settings>=2.0.0
cryptography>=42.0.0
python-jose[cryptography]>=3.3.0
passlib[bcrypt]>=1.7.4
bcrypt>=4.0.0
python-multipart>=0.0.6
httpx>=0.26.0
redis>=5.0.0
openpyxl>=3.1.0
apscheduler>=3.10.0

View File

@@ -1,16 +1,20 @@
FROM python:3.11-slim
WORKDIR /app
# 安装依赖(使用阿里云镜像)
COPY backend/requirements.txt .
RUN pip install --no-cache-dir -i https://mirrors.aliyun.com/pypi/simple/ --trusted-host mirrors.aliyun.com -r requirements.txt
# 复制代码
COPY backend/app ./app
# 暴露端口
EXPOSE 8000
# 启动命令
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
FROM python:3.11-slim
# 设置时区为上海
ENV TZ=Asia/Shanghai
RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone
WORKDIR /app
# 安装依赖(使用阿里云镜像)
COPY backend/requirements.txt .
RUN pip install --no-cache-dir -i https://mirrors.aliyun.com/pypi/simple/ --trusted-host mirrors.aliyun.com -r requirements.txt
# 复制代码
COPY backend/app ./app
# 暴露端口
EXPOSE 8000
# 启动命令
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]

358
docs/scheduled-tasks.md Normal file
View 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

View File

@@ -1,26 +1,28 @@
{
"name": "000-platform-admin",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"vue": "^3.4.0",
"vue-router": "^4.2.0",
"pinia": "^2.1.0",
"axios": "^1.6.0",
"element-plus": "^2.5.0",
"@element-plus/icons-vue": "^2.3.0",
"echarts": "^5.4.0",
"dayjs": "^1.11.0"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.0.0",
"vite": "^5.0.0",
"sass": "^1.69.0"
}
}
{
"name": "000-platform-admin",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"vue": "^3.4.0",
"vue-router": "^4.2.0",
"pinia": "^2.1.0",
"axios": "^1.6.0",
"element-plus": "^2.5.0",
"@element-plus/icons-vue": "^2.3.0",
"echarts": "^5.4.0",
"dayjs": "^1.11.0",
"monaco-editor": "^0.45.0",
"@monaco-editor/loader": "^1.4.0"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.0.0",
"vite": "^5.0.0",
"sass": "^1.69.0"
}
}

View File

@@ -1,97 +1,123 @@
import axios from 'axios'
import { ElMessage } from 'element-plus'
import router from '@/router'
const api = axios.create({
baseURL: '',
timeout: 30000
})
/**
* 解析 API 错误响应
*/
function parseApiError(error) {
const result = {
code: 'UNKNOWN_ERROR',
message: '发生了未知错误',
traceId: '',
status: 500
}
if (!error.response) {
result.code = 'NETWORK_ERROR'
result.message = '网络连接失败,请检查网络后重试'
return result
}
const { status, data, headers } = error.response
result.status = status
result.traceId = headers['x-trace-id'] || headers['X-Trace-ID'] || ''
if (data && data.error) {
result.code = data.error.code || result.code
result.message = data.error.message || result.message
result.traceId = data.error.trace_id || result.traceId
} else if (data && data.detail) {
result.message = typeof data.detail === 'string' ? data.detail : JSON.stringify(data.detail)
}
return result
}
/**
* 跳转到错误页面
*/
function navigateToErrorPage(errorInfo) {
router.push({
name: 'Error',
query: {
code: errorInfo.code,
message: errorInfo.message,
trace_id: errorInfo.traceId,
status: String(errorInfo.status)
}
})
}
// 请求拦截器
api.interceptors.request.use(
config => {
const token = localStorage.getItem('token')
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
},
error => Promise.reject(error)
)
// 响应拦截器(集成 TraceID 追踪)
api.interceptors.response.use(
response => response,
error => {
const errorInfo = parseApiError(error)
const traceLog = errorInfo.traceId ? ` (trace: ${errorInfo.traceId})` : ''
console.error(`[API Error] ${errorInfo.code}: ${errorInfo.message}${traceLog}`)
if (error.response?.status === 401) {
localStorage.removeItem('token')
localStorage.removeItem('user')
router.push('/login')
ElMessage.error('登录已过期,请重新登录')
} else if (error.response?.status === 403) {
ElMessage.error('没有权限执行此操作')
} else if (['INTERNAL_ERROR', 'SERVICE_UNAVAILABLE', 'GATEWAY_ERROR'].includes(errorInfo.code)) {
// 严重错误跳转到错误页面
navigateToErrorPage(errorInfo)
} else {
// 普通错误显示消息
ElMessage.error(errorInfo.message)
}
return Promise.reject(error)
}
)
export default api
import axios from 'axios'
import { ElMessage } from 'element-plus'
import router from '@/router'
const api = axios.create({
baseURL: '',
timeout: 30000
})
/**
* 解析 API 错误响应
*/
function parseApiError(error) {
const result = {
code: 'UNKNOWN_ERROR',
message: '发生了未知错误',
traceId: '',
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
}
const { status, data, headers } = error.response
result.status = status
result.traceId = headers['x-trace-id'] || headers['X-Trace-ID'] || ''
if (data && data.error) {
result.code = data.error.code || result.code
result.message = data.error.message || result.message
result.traceId = data.error.trace_id || result.traceId
} else if (data && data.detail) {
result.message = typeof data.detail === 'string' ? data.detail : JSON.stringify(data.detail)
}
return result
}
/**
* 跳转到错误页面(使用 sessionStorage + replace不影响浏览器历史
*/
function navigateToErrorPage(errorInfo) {
// 记录当前页面路径(用于返回)
sessionStorage.setItem('errorFromPath', router.currentRoute.value.fullPath)
// 保存错误信息到 sessionStorage不会显示在 URL 中)
sessionStorage.setItem('errorInfo', JSON.stringify({
code: errorInfo.code,
message: errorInfo.message,
traceId: errorInfo.traceId,
status: errorInfo.status,
timestamp: Date.now()
}))
// 使用 replace 而不是 push这样浏览器返回时不会停留在错误页
router.replace({ name: 'Error' })
}
// 请求拦截器
api.interceptors.request.use(
config => {
const token = localStorage.getItem('token')
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
},
error => Promise.reject(error)
)
// 响应拦截器(集成 TraceID 追踪)
api.interceptors.response.use(
response => response,
error => {
const errorInfo = parseApiError(error)
const traceLog = errorInfo.traceId ? ` (trace: ${errorInfo.traceId})` : ''
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')
router.push('/login')
ElMessage.error('登录已过期,请重新登录')
} else if (error.response?.status === 403) {
ElMessage.error('没有权限执行此操作')
} else if (criticalErrors.includes(errorInfo.code)) {
// 严重错误(包括网络错误、服务不可用)跳转到错误页面
navigateToErrorPage(errorInfo)
} else {
// 普通错误显示消息
ElMessage.error(errorInfo.message)
}
return Promise.reject(error)
}
)
export default api

View File

@@ -1,107 +1,109 @@
<script setup>
import { computed } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import { ElMessageBox } from 'element-plus'
const router = useRouter()
const route = useRoute()
const authStore = useAuthStore()
// 菜单项
const menuItems = computed(() => {
const items = [
{ path: '/dashboard', title: '仪表盘', icon: 'Odometer' },
{ path: '/tenants', title: '租户管理', icon: 'OfficeBuilding' },
{ path: '/apps', title: '应用管理', icon: 'Grid' },
{ path: '/tenant-wechat-apps', title: '企微应用', icon: 'ChatDotRound' },
{ path: '/app-config', title: '租户订阅', icon: 'Setting' },
{ path: '/stats', title: '统计分析', icon: 'TrendCharts' },
{ path: '/logs', title: '日志查看', icon: 'Document' }
]
// 管理员才能看到用户管理
if (authStore.isAdmin) {
items.push({ path: '/users', title: '用户管理', icon: 'User' })
}
return items
})
const activeMenu = computed(() => route.path)
function handleMenuSelect(path) {
router.push(path)
}
function handleLogout() {
ElMessageBox.confirm('确定要退出登录吗?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(() => {
authStore.logout()
router.push('/login')
})
}
</script>
<template>
<div class="layout">
<!-- 侧边栏 -->
<aside class="sidebar">
<div class="logo">
<el-icon><Platform /></el-icon>
<span style="margin-left: 8px">平台管理</span>
</div>
<el-menu
:default-active="activeMenu"
background-color="transparent"
text-color="rgba(255,255,255,0.7)"
active-text-color="#fff"
@select="handleMenuSelect"
>
<el-menu-item v-for="item in menuItems" :key="item.path" :index="item.path">
<el-icon><component :is="item.icon" /></el-icon>
<span>{{ item.title }}</span>
</el-menu-item>
</el-menu>
</aside>
<!-- 主内容区 -->
<div class="main-container">
<!-- 顶部栏 -->
<header class="header">
<div class="breadcrumb">
<el-breadcrumb separator="/">
<el-breadcrumb-item :to="{ path: '/' }">首页</el-breadcrumb-item>
<el-breadcrumb-item v-if="route.meta.title">{{ route.meta.title }}</el-breadcrumb-item>
</el-breadcrumb>
</div>
<div class="user-info">
<span class="username">{{ authStore.user?.nickname || authStore.user?.username }}</span>
<el-dropdown trigger="click">
<el-avatar :size="32">
{{ (authStore.user?.nickname || authStore.user?.username || 'U')[0].toUpperCase() }}
</el-avatar>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item @click="handleLogout">
<el-icon><SwitchButton /></el-icon>
退出登录
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</header>
<!-- 内容区 -->
<main class="main-content">
<router-view />
</main>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import { ElMessageBox } from 'element-plus'
const router = useRouter()
const route = useRoute()
const authStore = useAuthStore()
// 菜单项
const menuItems = computed(() => {
const items = [
{ path: '/dashboard', title: '仪表盘', icon: 'Odometer' },
{ path: '/tenants', title: '租户管理', icon: 'OfficeBuilding' },
{ path: '/apps', title: '应用管理', icon: 'Grid' },
{ path: '/tenant-wechat-apps', title: '企微应用', icon: 'ChatDotRound' },
{ path: '/app-config', title: '租户订阅', icon: 'Setting' },
{ path: '/stats', title: '统计分析', icon: 'TrendCharts' },
{ path: '/logs', title: '日志查看', icon: 'Document' },
{ path: '/scheduled-tasks', title: '定时任务', icon: 'Clock' },
{ path: '/notification-channels', title: '通知渠道', icon: 'Bell' }
]
// 管理员才能看到用户管理
if (authStore.isAdmin) {
items.push({ path: '/users', title: '用户管理', icon: 'User' })
}
return items
})
const activeMenu = computed(() => route.path)
function handleMenuSelect(path) {
router.push(path)
}
function handleLogout() {
ElMessageBox.confirm('确定要退出登录吗?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(() => {
authStore.logout()
router.push('/login')
})
}
</script>
<template>
<div class="layout">
<!-- 侧边栏 -->
<aside class="sidebar">
<div class="logo">
<el-icon><Platform /></el-icon>
<span style="margin-left: 8px">平台管理</span>
</div>
<el-menu
:default-active="activeMenu"
background-color="transparent"
text-color="rgba(255,255,255,0.7)"
active-text-color="#fff"
@select="handleMenuSelect"
>
<el-menu-item v-for="item in menuItems" :key="item.path" :index="item.path">
<el-icon><component :is="item.icon" /></el-icon>
<span>{{ item.title }}</span>
</el-menu-item>
</el-menu>
</aside>
<!-- 主内容区 -->
<div class="main-container">
<!-- 顶部栏 -->
<header class="header">
<div class="breadcrumb">
<el-breadcrumb separator="/">
<el-breadcrumb-item :to="{ path: '/' }">首页</el-breadcrumb-item>
<el-breadcrumb-item v-if="route.meta.title">{{ route.meta.title }}</el-breadcrumb-item>
</el-breadcrumb>
</div>
<div class="user-info">
<span class="username">{{ authStore.user?.nickname || authStore.user?.username }}</span>
<el-dropdown trigger="click">
<el-avatar :size="32">
{{ (authStore.user?.nickname || authStore.user?.username || 'U')[0].toUpperCase() }}
</el-avatar>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item @click="handleLogout">
<el-icon><SwitchButton /></el-icon>
退出登录
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</header>
<!-- 内容区 -->
<main class="main-content">
<router-view />
</main>
</div>
</div>
</template>

View File

@@ -72,6 +72,18 @@ const routes = [
name: 'Users',
component: () => import('@/views/users/index.vue'),
meta: { title: '用户管理', icon: 'User', role: 'admin' }
},
{
path: 'scheduled-tasks',
name: 'ScheduledTasks',
component: () => import('@/views/scheduled-tasks/index.vue'),
meta: { title: '定时任务', icon: 'Clock' }
},
{
path: 'notification-channels',
name: 'NotificationChannels',
component: () => import('@/views/notification-channels/index.vue'),
meta: { title: '通知渠道', icon: 'Bell' }
}
]
}

View File

@@ -1,7 +1,7 @@
<script setup>
import { ref, reactive, onMounted, computed, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Delete, Plus } from '@element-plus/icons-vue'
import { Delete, Plus, CopyDocument, Grid, Edit } from '@element-plus/icons-vue'
import api from '@/api'
import { useAuthStore } from '@/stores/auth'
@@ -19,9 +19,11 @@ const query = reactive({
// 租户列表
const tenantList = ref([])
const tenantMap = ref({}) // code -> name 映射
// 应用列表(从应用管理获取)
const appList = ref([])
const appMap = ref({}) // app_code -> app_name 映射
const appRequireJssdk = ref({}) // app_code -> require_jssdk
const appBaseUrl = ref({}) // app_code -> base_url
const appConfigSchema = ref({}) // app_code -> config_schema
@@ -85,6 +87,36 @@ function getOptionLabel(schema, optionValue) {
return optionValue
}
// 打开长文本编辑弹窗
function openTextEdit(index, schema) {
textEditData.index = index
textEditData.label = schema.label || schema.key
textEditData.value = form.custom_configs[index]?.value || ''
textEditData.placeholder = schema.placeholder || '请输入内容'
textEditDialogVisible.value = true
}
// 保存长文本编辑
function saveTextEdit() {
if (textEditData.index >= 0 && form.custom_configs[textEditData.index]) {
form.custom_configs[textEditData.index].value = textEditData.value
}
textEditDialogVisible.value = false
}
// 获取文本预览截取前N个字符
function getTextPreview(text, maxLength = 80) {
if (!text) return '(未填写)'
if (text.length <= maxLength) return text
return text.slice(0, maxLength) + '...'
}
// 判断文本是否为长文本超过80字符或多行
function isLongText(text) {
if (!text) return false
return text.length > 80 || text.includes('\n')
}
// 验证 app_code 必须是有效的应用
const validateAppCode = (rule, value, callback) => {
if (!value) {
@@ -124,26 +156,90 @@ const tokenDialogVisible = ref(false)
const currentToken = ref('')
const currentAppUrl = ref('')
// 批量添加对话框
const batchDialogVisible = ref(false)
const batchLoading = ref(false)
const batchForm = reactive({
tenant_id: '',
selected_apps: []
})
// 长文本编辑弹窗
const textEditDialogVisible = ref(false)
const textEditData = reactive({
index: -1,
label: '',
value: '',
placeholder: ''
})
// 获取租户尚未订阅的应用列表
const availableAppsForBatch = computed(() => {
if (!batchForm.tenant_id) return appList.value
// 获取该租户已订阅的应用
const subscribedApps = new Set(
tableData.value
.filter(row => row.tenant_id === batchForm.tenant_id)
.map(row => row.app_code)
)
// 返回未订阅的应用
return appList.value.filter(app => !subscribedApps.has(app.app_code))
})
// 是否全选
const isAllSelected = computed(() => {
return availableAppsForBatch.value.length > 0 &&
batchForm.selected_apps.length === availableAppsForBatch.value.length
})
// 是否部分选中
const isIndeterminate = computed(() => {
return batchForm.selected_apps.length > 0 &&
batchForm.selected_apps.length < availableAppsForBatch.value.length
})
async function fetchTenants() {
try {
const res = await api.get('/api/tenants', { params: { size: 1000 } })
tenantList.value = res.data.items || []
// 构建 code -> name 映射
const map = {}
tenantList.value.forEach(t => {
map[t.code] = t.name
})
tenantMap.value = map
} catch (e) {
console.error('获取租户列表失败:', e)
}
}
// 获取租户中文名
function getTenantName(code) {
return tenantMap.value[code] || code
}
// 获取应用中文名
function getAppName(code) {
return appMap.value[code] || code
}
async function fetchApps() {
try {
const res = await api.get('/api/apps', { params: { size: 100 } })
const apps = res.data.items || []
appList.value = apps.map(a => ({ app_code: a.app_code, app_name: a.app_name }))
// 构建 app_code -> app_name 映射
const map = {}
for (const app of apps) {
map[app.app_code] = app.app_name
appRequireJssdk.value[app.app_code] = app.require_jssdk || false
appBaseUrl.value[app.app_code] = app.base_url || ''
appConfigSchema.value[app.app_code] = app.config_schema || []
}
appMap.value = map
} catch (e) {
console.error('获取应用列表失败:', e)
}
@@ -338,6 +434,153 @@ async function handleViewToken(row) {
}
}
// 快速选择租户标签
function selectTenant(code) {
if (query.tenant_id === code) {
query.tenant_id = '' // 再次点击取消选中
} else {
query.tenant_id = code
}
handleSearch()
}
// 复制带 token 的链接
async function copyTokenLink(row) {
try {
const res = await api.get(`/api/tenant-apps/${row.id}/token`)
const token = res.data.access_token
const baseUrl = res.data.base_url || appBaseUrl.value[row.app_code] || ''
if (!baseUrl) {
ElMessage.warning('该应用未配置访问地址')
return
}
const url = `${baseUrl}?token=${token}`
await navigator.clipboard.writeText(url)
ElMessage.success('链接已复制')
} catch (e) {
ElMessage.error('复制失败')
}
}
// 打开批量添加对话框
function handleBatchCreate() {
batchForm.tenant_id = ''
batchForm.selected_apps = []
batchDialogVisible.value = true
}
// 监听批量添加租户变化
watch(() => batchForm.tenant_id, () => {
batchForm.selected_apps = []
})
// 全选/取消全选
function toggleSelectAll(checked) {
if (checked) {
batchForm.selected_apps = availableAppsForBatch.value.map(app => app.app_code)
} else {
batchForm.selected_apps = []
}
}
// 批量创建订阅
async function handleBatchSubmit() {
if (!batchForm.tenant_id) {
ElMessage.warning('请选择租户')
return
}
if (batchForm.selected_apps.length === 0) {
ElMessage.warning('请至少选择一个应用')
return
}
batchLoading.value = true
const results = { success: 0, failed: 0 }
const createdTokens = []
try {
for (const appCode of batchForm.selected_apps) {
try {
const res = await api.post('/api/tenant-apps', {
tenant_id: batchForm.tenant_id,
app_code: appCode,
app_name: '',
custom_configs: []
})
results.success++
// 收集生成的 token 信息
if (res.data.access_token) {
createdTokens.push({
app_code: appCode,
app_name: appMap.value[appCode] || appCode,
token: res.data.access_token,
base_url: appBaseUrl.value[appCode] || ''
})
}
} catch (e) {
results.failed++
console.error(`创建 ${appCode} 订阅失败:`, e)
}
}
if (results.success > 0) {
ElMessage.success(`成功创建 ${results.success} 个订阅${results.failed > 0 ? `${results.failed} 个失败` : ''}`)
batchDialogVisible.value = false
fetchList()
// 如果有创建成功的,显示批量 Token 结果
if (createdTokens.length > 0) {
showBatchTokens(createdTokens)
}
} else {
ElMessage.error('创建失败,可能应用已被订阅')
}
} finally {
batchLoading.value = false
}
}
// 批量 Token 显示
const batchTokenDialogVisible = ref(false)
const batchTokenList = ref([])
function showBatchTokens(tokens) {
batchTokenList.value = tokens
batchTokenDialogVisible.value = true
}
// 复制单个 Token 链接
function copyBatchTokenLink(item) {
if (!item.base_url) {
ElMessage.warning('该应用未配置访问地址')
return
}
const url = `${item.base_url}?token=${item.token}`
navigator.clipboard.writeText(url).then(() => {
ElMessage.success(`${item.app_name} 链接已复制`)
})
}
// 复制所有 Token 链接
function copyAllTokenLinks() {
const links = batchTokenList.value
.filter(item => item.base_url)
.map(item => `${item.app_name}: ${item.base_url}?token=${item.token}`)
.join('\n\n')
if (!links) {
ElMessage.warning('没有可复制的链接')
return
}
navigator.clipboard.writeText(links).then(() => {
ElMessage.success('所有链接已复制')
})
}
onMounted(() => {
fetchTenants()
fetchApps()
@@ -349,10 +592,16 @@ onMounted(() => {
<div class="page-container">
<div class="page-header">
<div class="title">租户应用订阅</div>
<el-button v-if="authStore.isOperator" type="primary" @click="handleCreate">
<el-icon><Plus /></el-icon>
新建订阅
</el-button>
<div class="header-actions">
<el-button v-if="authStore.isOperator" type="success" @click="handleBatchCreate">
<el-icon><Grid /></el-icon>
批量添加
</el-button>
<el-button v-if="authStore.isOperator" type="primary" @click="handleCreate">
<el-icon><Plus /></el-icon>
新建订阅
</el-button>
</div>
</div>
<div class="page-tip">
@@ -361,16 +610,32 @@ onMounted(() => {
</el-alert>
</div>
<!-- 租户快速筛选标签 -->
<div class="tenant-tags">
<span class="tag-label">租户筛选</span>
<el-tag
v-for="tenant in tenantList"
:key="tenant.code"
:type="query.tenant_id === tenant.code ? '' : 'info'"
:effect="query.tenant_id === tenant.code ? 'dark' : 'plain'"
class="tenant-tag"
@click="selectTenant(tenant.code)"
>
{{ tenant.name }}
</el-tag>
<el-tag
v-if="query.tenant_id"
type="danger"
effect="plain"
class="tenant-tag clear-tag"
@click="selectTenant('')"
>
清除筛选
</el-tag>
</div>
<!-- 搜索栏 -->
<div class="search-bar">
<el-select v-model="query.tenant_id" placeholder="选择租户" clearable filterable style="width: 200px">
<el-option
v-for="tenant in tenantList"
:key="tenant.code"
:label="`${tenant.name} (${tenant.code})`"
:value="tenant.code"
/>
</el-select>
<el-select v-model="query.app_code" placeholder="选择应用" clearable style="width: 150px">
<el-option v-for="app in appList" :key="app.app_code" :label="app.app_name" :value="app.app_code" />
</el-select>
@@ -380,10 +645,33 @@ onMounted(() => {
<!-- 表格 -->
<el-table v-loading="loading" :data="tableData" style="width: 100%">
<el-table-column prop="id" label="ID" width="60" />
<el-table-column prop="tenant_id" label="租户ID" width="120" />
<el-table-column prop="app_code" label="应用代码" width="150" />
<el-table-column prop="app_name" label="备注名称" width="150" />
<el-table-column label="企微应用" width="180">
<el-table-column label="租户" width="120">
<template #default="{ row }">
<span class="cell-name">{{ getTenantName(row.tenant_id) }}</span>
</template>
</el-table-column>
<el-table-column label="应用" width="150">
<template #default="{ row }">
<span class="cell-name">{{ getAppName(row.app_code) }}</span>
</template>
</el-table-column>
<el-table-column prop="app_name" label="备注" width="120" />
<el-table-column label="快捷链接" width="100">
<template #default="{ row }">
<el-button
v-if="row.access_token && appBaseUrl[row.app_code]"
type="primary"
link
size="small"
@click="copyTokenLink(row)"
>
<el-icon><CopyDocument /></el-icon>
复制
</el-button>
<span v-else style="color: #c0c4cc; font-size: 12px">-</span>
</template>
</el-table-column>
<el-table-column label="企微应用" width="140">
<template #default="{ row }">
<template v-if="row.wechat_app">
<el-tag type="success" size="small">{{ row.wechat_app.name }}</el-tag>
@@ -391,13 +679,7 @@ onMounted(() => {
<el-tag v-else type="info" size="small">未关联</el-tag>
</template>
</el-table-column>
<el-table-column label="Token 状态" width="120">
<template #default="{ row }">
<el-tag v-if="row.access_token" type="success" size="small">已生成</el-tag>
<el-tag v-else type="danger" size="small">未生成</el-tag>
</template>
</el-table-column>
<el-table-column label="自定义配置" width="100">
<el-table-column label="配置" width="80">
<template #default="{ row }">
<el-tag v-if="row.custom_configs && row.custom_configs.length > 0" type="primary" size="small">
{{ row.custom_configs.length }}
@@ -405,18 +687,18 @@ onMounted(() => {
<span v-else style="color: #909399; font-size: 12px">-</span>
</template>
</el-table-column>
<el-table-column label="状态" width="80">
<el-table-column label="状态" width="70">
<template #default="{ row }">
<el-tag :type="row.status === 1 ? 'success' : 'info'" size="small">
{{ row.status === 1 ? '启用' : '禁用' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="280" fixed="right">
<el-table-column label="操作" width="240" fixed="right">
<template #default="{ row }">
<el-button v-if="authStore.isOperator" type="primary" link size="small" @click="handleEdit(row)">编辑</el-button>
<el-button v-if="authStore.isOperator" type="success" link size="small" @click="handleViewToken(row)">查看Token</el-button>
<el-button v-if="authStore.isOperator" type="warning" link size="small" @click="handleRegenerateToken(row)">重置Token</el-button>
<el-button v-if="authStore.isOperator" type="success" link size="small" @click="handleViewToken(row)">Token</el-button>
<el-button v-if="authStore.isOperator" type="warning" link size="small" @click="handleRegenerateToken(row)">重置</el-button>
<el-button v-if="authStore.isOperator" type="danger" link size="small" @click="handleDelete(row)">删除</el-button>
</template>
</el-table-column>
@@ -434,8 +716,14 @@ onMounted(() => {
</div>
<!-- 编辑对话框 -->
<el-dialog v-model="dialogVisible" :title="dialogTitle" width="750px">
<el-form ref="formRef" :model="form" :rules="rules" label-width="120px">
<el-dialog
v-model="dialogVisible"
:title="dialogTitle"
width="750px"
:lock-scroll="true"
class="config-dialog"
>
<el-form ref="formRef" :model="form" :rules="rules" label-width="120px" class="config-form">
<el-form-item label="租户" prop="tenant_id">
<el-select
v-model="form.tenant_id"
@@ -497,13 +785,32 @@ onMounted(() => {
<span v-if="schema.required" class="required-star">*</span>
</div>
<!-- text 类型 -->
<!-- text 类型 - 短文本直接编辑长文本用弹窗 -->
<template v-if="schema.type === 'text'">
<!-- 长文本使用预览+弹窗编辑 -->
<div
v-if="isLongText(form.custom_configs[index]?.value) || schema.key?.includes('prompt')"
class="text-preview-container"
>
<div class="text-preview" @click="openTextEdit(index, schema)">
{{ getTextPreview(form.custom_configs[index]?.value, 100) }}
</div>
<el-button
type="primary"
size="small"
@click="openTextEdit(index, schema)"
>
<el-icon><Edit /></el-icon>
编辑
</el-button>
</div>
<!-- 短文本直接编辑 -->
<el-input
v-else
v-model="form.custom_configs[index].value"
type="textarea"
:rows="2"
:autosize="{ minRows: 2, maxRows: 12 }"
:autosize="{ minRows: 1, maxRows: 4 }"
:placeholder="schema.placeholder || '请输入'"
/>
</template>
@@ -645,6 +952,148 @@ onMounted(() => {
<el-button @click="tokenDialogVisible = false">关闭</el-button>
</template>
</el-dialog>
<!-- 批量添加对话框 -->
<el-dialog v-model="batchDialogVisible" title="批量添加应用订阅" width="600px">
<el-form label-width="100px">
<el-form-item label="选择租户" required>
<el-select
v-model="batchForm.tenant_id"
placeholder="请选择租户"
filterable
style="width: 100%"
>
<el-option
v-for="tenant in tenantList"
:key="tenant.code"
:label="`${tenant.name} (${tenant.code})`"
:value="tenant.code"
/>
</el-select>
</el-form-item>
<el-form-item label="选择应用" required>
<div class="batch-apps-container">
<div class="batch-apps-header">
<el-checkbox
:model-value="isAllSelected"
:indeterminate="isIndeterminate"
@change="toggleSelectAll"
:disabled="!batchForm.tenant_id || availableAppsForBatch.length === 0"
>
全选 ({{ batchForm.selected_apps.length }}/{{ availableAppsForBatch.length }})
</el-checkbox>
</div>
<el-empty
v-if="!batchForm.tenant_id"
description="请先选择租户"
:image-size="60"
/>
<el-empty
v-else-if="availableAppsForBatch.length === 0"
description="该租户已订阅所有应用"
:image-size="60"
/>
<el-checkbox-group
v-else
v-model="batchForm.selected_apps"
class="batch-apps-list"
>
<el-checkbox
v-for="app in availableAppsForBatch"
:key="app.app_code"
:value="app.app_code"
class="batch-app-item"
>
{{ app.app_name }}
<span class="app-code">({{ app.app_code }})</span>
</el-checkbox>
</el-checkbox-group>
</div>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="batchDialogVisible = false">取消</el-button>
<el-button
type="primary"
:loading="batchLoading"
:disabled="!batchForm.tenant_id || batchForm.selected_apps.length === 0"
@click="handleBatchSubmit"
>
创建 {{ batchForm.selected_apps.length }} 个订阅
</el-button>
</template>
</el-dialog>
<!-- 批量 Token 结果对话框 -->
<el-dialog v-model="batchTokenDialogVisible" title="批量创建成功" width="700px">
<el-alert type="success" :closable="false" style="margin-bottom: 16px">
已成功创建 {{ batchTokenList.length }} 个应用订阅以下是访问链接
</el-alert>
<div class="batch-token-list">
<div
v-for="item in batchTokenList"
:key="item.app_code"
class="batch-token-item"
>
<div class="batch-token-info">
<span class="batch-token-name">{{ item.app_name }}</span>
<el-tag v-if="!item.base_url" type="warning" size="small">无链接</el-tag>
</div>
<div v-if="item.base_url" class="batch-token-url">
{{ item.base_url }}?token={{ item.token.slice(0, 20) }}...
</div>
<el-button
v-if="item.base_url"
type="primary"
size="small"
@click="copyBatchTokenLink(item)"
>
<el-icon><CopyDocument /></el-icon>
复制
</el-button>
</div>
</div>
<template #footer>
<el-button @click="copyAllTokenLinks" type="success">
<el-icon><CopyDocument /></el-icon>
复制全部链接
</el-button>
<el-button @click="batchTokenDialogVisible = false">关闭</el-button>
</template>
</el-dialog>
<!-- 长文本编辑弹窗 - 接近全屏 -->
<el-dialog
v-model="textEditDialogVisible"
:title="`编辑:${textEditData.label}`"
width="90vw"
top="5vh"
:close-on-click-modal="false"
:lock-scroll="true"
:append-to-body="true"
class="text-edit-dialog"
>
<div class="text-edit-wrapper">
<el-input
v-model="textEditData.value"
type="textarea"
:placeholder="textEditData.placeholder"
class="text-edit-textarea"
resize="none"
/>
</div>
<div class="text-edit-stats">
字符数{{ textEditData.value?.length || 0 }}
</div>
<template #footer>
<el-button @click="textEditDialogVisible = false">取消</el-button>
<el-button type="primary" @click="saveTextEdit">保存</el-button>
</template>
</el-dialog>
</div>
</template>
@@ -653,6 +1102,207 @@ onMounted(() => {
margin-bottom: 16px;
}
/* 租户标签筛选 */
.tenant-tags {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 16px;
padding: 12px 16px;
background: #f5f7fa;
border-radius: 8px;
}
.tag-label {
color: #606266;
font-size: 13px;
font-weight: 500;
margin-right: 4px;
}
.tenant-tag {
cursor: pointer;
transition: all 0.2s;
}
.tenant-tag:hover {
transform: translateY(-1px);
}
.clear-tag {
margin-left: 8px;
}
.cell-name {
font-weight: 500;
color: #303133;
}
.header-actions {
display: flex;
gap: 10px;
}
/* 批量添加对话框样式 */
.batch-apps-container {
border: 1px solid #dcdfe6;
border-radius: 8px;
padding: 12px;
background: #fafafa;
}
.batch-apps-header {
padding-bottom: 12px;
border-bottom: 1px solid #ebeef5;
margin-bottom: 12px;
}
.batch-apps-list {
display: flex;
flex-direction: column;
gap: 8px;
max-height: 300px;
overflow-y: auto;
}
.batch-app-item {
display: flex;
align-items: center;
padding: 8px 12px;
background: white;
border-radius: 6px;
border: 1px solid #ebeef5;
transition: all 0.2s;
}
.batch-app-item:hover {
border-color: #409eff;
}
.app-code {
color: #909399;
font-size: 12px;
margin-left: 4px;
}
/* 批量 Token 结果样式 */
.batch-token-list {
max-height: 400px;
overflow-y: auto;
}
.batch-token-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px;
background: #f5f7fa;
border-radius: 8px;
margin-bottom: 8px;
}
.batch-token-item:last-child {
margin-bottom: 0;
}
.batch-token-info {
display: flex;
align-items: center;
gap: 8px;
}
.batch-token-name {
font-weight: 500;
color: #303133;
}
.batch-token-url {
flex: 1;
color: #909399;
font-size: 12px;
margin: 0 16px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* 长文本预览样式 */
.text-preview-container {
display: flex;
align-items: flex-start;
gap: 12px;
}
.text-preview {
flex: 1;
padding: 10px 12px;
background: #f5f7fa;
border: 1px solid #dcdfe6;
border-radius: 6px;
color: #606266;
font-size: 13px;
line-height: 1.5;
cursor: pointer;
transition: all 0.2s;
min-height: 40px;
max-height: 80px;
overflow: hidden;
text-overflow: ellipsis;
white-space: pre-wrap;
word-break: break-all;
}
.text-preview:hover {
border-color: #409eff;
background: #ecf5ff;
}
/* 长文本编辑弹窗样式 - 接近全屏 */
.text-edit-dialog :deep(.el-dialog) {
max-width: 1200px;
margin: 0 auto;
}
.text-edit-dialog :deep(.el-dialog__body) {
padding: 16px 20px;
}
.text-edit-wrapper {
height: calc(70vh - 60px);
}
.text-edit-wrapper :deep(.el-textarea) {
height: 100%;
}
.text-edit-wrapper :deep(.el-textarea__inner) {
font-family: 'Monaco', 'Menlo', 'Consolas', monospace;
font-size: 14px;
line-height: 1.7;
height: 100% !important;
resize: none;
padding: 16px;
}
.text-edit-stats {
margin-top: 12px;
text-align: right;
color: #909399;
font-size: 12px;
}
/* 配置对话框优化 */
.config-dialog :deep(.el-dialog__body) {
max-height: 60vh;
overflow-y: auto;
padding-right: 20px;
}
.config-form {
padding-right: 10px;
}
.token-dialog-content {
padding: 0 10px;
}

View File

@@ -1,179 +1,223 @@
<script setup>
/**
* 统一错误页面
*/
import { computed, ref } from 'vue'
import { useRoute, 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 copied = ref(false)
const errorConfig = computed(() => {
const configs = {
'UNAUTHORIZED': { icon: 'Lock', title: '访问受限', color: '#e67e22' },
'FORBIDDEN': { icon: 'CircleClose', title: '权限不足', color: '#e74c3c' },
'NOT_FOUND': { icon: 'Search', title: '页面未找到', color: '#3498db' },
'VALIDATION_ERROR': { icon: 'Warning', title: '请求参数错误', color: '#f39c12' },
'INTERNAL_ERROR': { icon: 'Close', title: '服务器错误', color: '#e74c3c' },
'SERVICE_UNAVAILABLE': { icon: 'Tools', title: '服务暂时不可用', color: '#9b59b6' },
'NETWORK_ERROR': { icon: 'Connection', title: '网络连接失败', color: '#95a5a6' },
'UNKNOWN_ERROR': { icon: 'QuestionFilled', title: '未知错误', color: '#7f8c8d' }
}
return configs[errorCode.value] || configs['UNKNOWN_ERROR']
})
const copyTraceId = async () => {
if (!traceId.value) return
try {
await navigator.clipboard.writeText(traceId.value)
copied.value = true
ElMessage.success('追踪码已复制')
setTimeout(() => { copied.value = false }, 2000)
} catch {
ElMessage.error('复制失败')
}
}
const goHome = () => router.push('/dashboard')
const retry = () => router.back()
</script>
<template>
<div class="error-page">
<div class="error-container">
<div class="error-icon-wrapper" :style="{ background: errorConfig.color + '20', color: errorConfig.color }">
<el-icon :size="48">
<component :is="errorConfig.icon" />
</el-icon>
</div>
<h1 class="error-title">{{ errorConfig.title }}</h1>
<div class="status-code">HTTP {{ statusCode }}</div>
<p class="error-message">{{ errorMessage }}</p>
<div class="trace-section" v-if="traceId">
<div class="trace-label">问题追踪码</div>
<div class="trace-id-box" @click="copyTraceId">
<code class="trace-id">{{ traceId }}</code>
<el-button type="primary" link size="small">
{{ copied ? '已复制' : '复制' }}
</el-button>
</div>
<p class="trace-tip">如需技术支持请提供此追踪码</p>
</div>
<div class="action-buttons">
<el-button type="primary" @click="retry">重试</el-button>
<el-button @click="goHome">返回首页</el-button>
</div>
</div>
</div>
</template>
<style scoped>
.error-page {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: #f5f7fa;
padding: 20px;
}
.error-container {
background: white;
border-radius: 12px;
padding: 48px;
max-width: 480px;
width: 100%;
text-align: center;
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.08);
}
.error-icon-wrapper {
width: 96px;
height: 96px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
margin: 0 auto 24px;
}
.error-title {
font-size: 24px;
font-weight: 600;
margin: 0 0 8px 0;
color: #303133;
}
.status-code {
font-size: 14px;
color: #909399;
margin-bottom: 16px;
}
.error-message {
font-size: 15px;
color: #606266;
margin: 0 0 24px 0;
line-height: 1.6;
}
.trace-section {
background: #f5f7fa;
border-radius: 8px;
padding: 16px;
margin-bottom: 24px;
}
.trace-label {
font-size: 12px;
color: #909399;
margin-bottom: 8px;
}
.trace-id-box {
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
padding: 8px 12px;
background: white;
border: 1px solid #e4e7ed;
border-radius: 6px;
cursor: pointer;
transition: all 0.2s;
}
.trace-id-box:hover {
border-color: #409eff;
}
.trace-id {
font-family: 'Monaco', 'Menlo', 'Consolas', monospace;
font-size: 13px;
color: #303133;
}
.trace-tip {
font-size: 12px;
color: #909399;
margin: 8px 0 0 0;
}
.action-buttons {
display: flex;
gap: 12px;
justify-content: center;
}
</style>
<script setup>
/**
* 统一错误页面
* - 从 sessionStorage 读取错误信息,不污染 URL
* - 使用 replace 跳转,支持浏览器返回
*/
import { ref, onMounted, onUnmounted } from 'vue'
import { useRouter } from 'vue-router'
import { ElButton, ElMessage } from 'element-plus'
const router = useRouter()
// 错误信息
const errorCode = ref('UNKNOWN_ERROR')
const errorMessage = ref('发生了未知错误')
const traceId = ref('')
const statusCode = ref('500')
const copied = ref(false)
// 记录来源页面(用于返回)
const fromPath = ref('')
// 错误类型配置
const errorConfigs = {
'UNAUTHORIZED': { icon: 'Lock', title: '访问受限', color: '#e67e22' },
'FORBIDDEN': { icon: 'CircleClose', title: '权限不足', color: '#e74c3c' },
'NOT_FOUND': { icon: 'Search', title: '页面未找到', color: '#3498db' },
'VALIDATION_ERROR': { icon: 'Warning', title: '请求参数错误', color: '#f39c12' },
'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']
}
// 读取后清除(避免刷新时重复显示旧错误)
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 () => {
if (!traceId.value) return
try {
await navigator.clipboard.writeText(traceId.value)
copied.value = true
ElMessage.success('追踪码已复制')
setTimeout(() => { copied.value = false }, 2000)
} catch {
ElMessage.error('复制失败')
}
}
const goHome = () => router.push('/dashboard')
// 返回之前的页面
const goBack = () => {
if (fromPath.value && fromPath.value !== '/error') {
router.push(fromPath.value)
} else {
router.push('/dashboard')
}
}
</script>
<template>
<div class="error-page">
<div class="error-container">
<div class="error-icon-wrapper" :style="{ background: errorConfig.color + '20', color: errorConfig.color }">
<el-icon :size="48">
<component :is="errorConfig.icon" />
</el-icon>
</div>
<h1 class="error-title">{{ errorConfig.title }}</h1>
<div class="status-code">HTTP {{ statusCode }}</div>
<p class="error-message">{{ errorMessage }}</p>
<div class="trace-section" v-if="traceId">
<div class="trace-label">问题追踪码</div>
<div class="trace-id-box" @click="copyTraceId">
<code class="trace-id">{{ traceId }}</code>
<el-button type="primary" link size="small">
{{ copied ? '已复制' : '复制' }}
</el-button>
</div>
<p class="trace-tip">如需技术支持请提供此追踪码</p>
</div>
<div class="action-buttons">
<el-button type="primary" @click="goBack">返回</el-button>
<el-button @click="goHome">返回首页</el-button>
</div>
</div>
</div>
</template>
<style scoped>
.error-page {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: #f5f7fa;
padding: 20px;
}
.error-container {
background: white;
border-radius: 12px;
padding: 48px;
max-width: 480px;
width: 100%;
text-align: center;
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.08);
}
.error-icon-wrapper {
width: 96px;
height: 96px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
margin: 0 auto 24px;
}
.error-title {
font-size: 24px;
font-weight: 600;
margin: 0 0 8px 0;
color: #303133;
}
.status-code {
font-size: 14px;
color: #909399;
margin-bottom: 16px;
}
.error-message {
font-size: 15px;
color: #606266;
margin: 0 0 24px 0;
line-height: 1.6;
}
.trace-section {
background: #f5f7fa;
border-radius: 8px;
padding: 16px;
margin-bottom: 24px;
}
.trace-label {
font-size: 12px;
color: #909399;
margin-bottom: 8px;
}
.trace-id-box {
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
padding: 8px 12px;
background: white;
border: 1px solid #e4e7ed;
border-radius: 6px;
cursor: pointer;
transition: all 0.2s;
}
.trace-id-box:hover {
border-color: #409eff;
}
.trace-id {
font-family: 'Monaco', 'Menlo', 'Consolas', monospace;
font-size: 13px;
color: #303133;
}
.trace-tip {
font-size: 12px;
color: #909399;
margin: 8px 0 0 0;
}
.action-buttons {
display: flex;
gap: 12px;
justify-content: center;
}
</style>

View File

@@ -0,0 +1,317 @@
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import api from '@/api'
const loading = ref(false)
const tableData = ref([])
const total = ref(0)
const query = reactive({
page: 1,
size: 50,
tenant_id: ''
})
// 租户列表
const tenants = ref([])
// 对话框
const dialogVisible = ref(false)
const dialogTitle = ref('')
const editingId = ref(null)
const formRef = ref(null)
const form = reactive({
tenant_id: '',
channel_name: '',
channel_type: 'dingtalk_bot',
webhook_url: '',
sign_secret: '',
description: ''
})
const rules = {
tenant_id: [{ required: true, message: '请选择租户', trigger: 'change' }],
channel_name: [{ required: true, message: '请输入渠道名称', trigger: 'blur' }],
channel_type: [{ required: true, message: '请选择渠道类型', trigger: 'change' }],
webhook_url: [{ required: true, message: '请输入 Webhook 地址', trigger: 'blur' }]
}
const channelTypes = [
{ value: 'dingtalk_bot', label: '钉钉机器人' },
{ value: 'wecom_bot', label: '企微机器人' }
]
async function fetchList() {
loading.value = true
try {
const params = { ...query }
if (!params.tenant_id) delete params.tenant_id
const res = await api.get('/api/notification-channels', { params })
tableData.value = res.data.items || []
total.value = res.data.total || 0
} catch (e) {
console.error(e)
} finally {
loading.value = false
}
}
async function fetchTenants() {
try {
const res = await api.get('/api/tenants', { params: { size: 1000 } })
tenants.value = res.data.items || []
} catch (e) {
console.error(e)
}
}
function handleSearch() {
query.page = 1
fetchList()
}
function handleCreate() {
editingId.value = null
dialogTitle.value = '新建通知渠道'
Object.assign(form, {
tenant_id: '',
channel_name: '',
channel_type: 'dingtalk_bot',
webhook_url: '',
sign_secret: '',
description: ''
})
dialogVisible.value = true
}
function handleEdit(row) {
editingId.value = row.id
dialogTitle.value = '编辑通知渠道'
Object.assign(form, {
tenant_id: row.tenant_id,
channel_name: row.channel_name,
channel_type: row.channel_type,
webhook_url: row.webhook_url,
sign_secret: row.sign_secret || '',
description: row.description || ''
})
dialogVisible.value = true
}
async function handleSubmit() {
await formRef.value.validate()
try {
if (editingId.value) {
await api.put(`/api/notification-channels/${editingId.value}`, form)
ElMessage.success('更新成功')
} else {
await api.post('/api/notification-channels', form)
ElMessage.success('创建成功')
}
dialogVisible.value = false
fetchList()
} catch (e) {
// 错误已在拦截器处理
}
}
async function handleDelete(row) {
await ElMessageBox.confirm(`确定删除渠道 "${row.channel_name}" 吗?`, '提示', {
type: 'warning'
})
try {
await api.delete(`/api/notification-channels/${row.id}`)
ElMessage.success('删除成功')
fetchList()
} catch (e) {
// 错误已在拦截器处理
}
}
async function handleToggle(row) {
try {
await api.put(`/api/notification-channels/${row.id}`, {
is_enabled: !row.is_enabled
})
ElMessage.success(row.is_enabled ? '已禁用' : '已启用')
fetchList()
} catch (e) {
// 错误已在拦截器处理
}
}
async function handleTest(row) {
try {
ElMessage.info('发送测试消息中...')
const res = await api.post(`/api/notification-channels/${row.id}/test`)
if (res.data.success) {
ElMessage.success('测试消息发送成功')
} else {
ElMessage.error(`发送失败: ${res.data.message}`)
}
} catch (e) {
// 错误已在拦截器处理
}
}
function getTenantName(tenantId) {
const tenant = tenants.value.find(t => t.code === tenantId)
return tenant ? tenant.name : tenantId
}
function getChannelTypeName(type) {
const item = channelTypes.find(t => t.value === type)
return item ? item.label : type
}
onMounted(() => {
fetchList()
fetchTenants()
})
</script>
<template>
<div class="page-container">
<div class="page-header">
<div class="title">通知渠道管理</div>
<el-button type="primary" @click="handleCreate">
<el-icon><Plus /></el-icon>
新建渠道
</el-button>
</div>
<div class="page-tip">
<el-alert type="info" :closable="false">
通知渠道用于定时任务执行后发送消息支持钉钉机器人和企微机器人
脚本中设置 <code>result = {'content': '消息内容', 'title': '标题'}</code> 变量任务执行后会自动发送到配置的渠道
</el-alert>
</div>
<!-- 筛选 -->
<div class="filter-bar">
<el-select v-model="query.tenant_id" placeholder="全部租户" clearable style="width: 180px">
<el-option v-for="t in tenants" :key="t.code" :label="t.name" :value="t.code" />
</el-select>
<el-button type="primary" @click="handleSearch">搜索</el-button>
</div>
<!-- 表格 -->
<el-table v-loading="loading" :data="tableData" style="width: 100%">
<el-table-column prop="id" label="ID" width="60" />
<el-table-column label="租户" width="120">
<template #default="{ row }">
{{ getTenantName(row.tenant_id) }}
</template>
</el-table-column>
<el-table-column prop="channel_name" label="渠道名称" min-width="150" />
<el-table-column label="类型" width="120">
<template #default="{ row }">
<el-tag :type="row.channel_type === 'dingtalk_bot' ? 'primary' : 'success'" size="small">
{{ getChannelTypeName(row.channel_type) }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="webhook_url" label="Webhook" min-width="200" show-overflow-tooltip />
<el-table-column label="状态" width="80">
<template #default="{ row }">
<el-tag :type="row.is_enabled ? 'success' : 'info'" size="small">
{{ row.is_enabled ? '启用' : '禁用' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="220" fixed="right">
<template #default="{ row }">
<el-button type="primary" link size="small" @click="handleEdit(row)">编辑</el-button>
<el-button type="info" link size="small" @click="handleTest(row)">测试</el-button>
<el-button :type="row.is_enabled ? 'warning' : 'success'" link size="small" @click="handleToggle(row)">
{{ row.is_enabled ? '禁用' : '启用' }}
</el-button>
<el-button type="danger" link size="small" @click="handleDelete(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<!-- 编辑对话框 -->
<el-dialog v-model="dialogVisible" :title="dialogTitle" width="550px">
<el-form ref="formRef" :model="form" :rules="rules" label-width="100px">
<el-form-item label="所属租户" prop="tenant_id">
<el-select v-model="form.tenant_id" placeholder="选择租户" style="width: 100%">
<el-option v-for="t in tenants" :key="t.code" :label="t.name" :value="t.code" />
</el-select>
</el-form-item>
<el-form-item label="渠道名称" prop="channel_name">
<el-input v-model="form.channel_name" placeholder="如: 销售群机器人" />
</el-form-item>
<el-form-item label="渠道类型" prop="channel_type">
<el-select v-model="form.channel_type" style="width: 100%">
<el-option v-for="t in channelTypes" :key="t.value" :label="t.label" :value="t.value" />
</el-select>
</el-form-item>
<el-form-item label="Webhook" prop="webhook_url">
<el-input v-model="form.webhook_url" placeholder="机器人 Webhook 地址" />
<div class="form-tip">
<template v-if="form.channel_type === 'dingtalk_bot'">
钉钉机器人 Webhook 格式: https://oapi.dingtalk.com/robot/send?access_token=xxx
</template>
<template v-else>
企微机器人 Webhook 格式: https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=xxx
</template>
</div>
</el-form-item>
<el-form-item v-if="form.channel_type === 'dingtalk_bot'" label="加签密钥">
<el-input v-model="form.sign_secret" placeholder="SEC开头的密钥可选" />
<div class="form-tip">
如果创建机器人时选择了加签安全设置请填写密钥 SEC 开头
</div>
</el-form-item>
<el-form-item label="描述">
<el-input v-model="form.description" type="textarea" :rows="2" placeholder="渠道描述(可选)" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="handleSubmit">保存</el-button>
</template>
</el-dialog>
</div>
</template>
<style scoped>
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.page-header .title {
font-size: 20px;
font-weight: 600;
}
.page-tip {
margin-bottom: 16px;
}
.page-tip code {
background: #f5f7fa;
padding: 2px 6px;
border-radius: 4px;
font-size: 12px;
}
.filter-bar {
display: flex;
gap: 12px;
margin-bottom: 20px;
}
.form-tip {
color: #909399;
font-size: 12px;
margin-top: 4px;
}
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -230,8 +230,137 @@ watch(activeTab, (newVal) => {
fetchToolConfigs()
fetchConfigSchema()
}
if (newVal === 'ruimeiyun' && !ruimeiyunLoaded.value) {
fetchRuimeiyunConfig()
}
})
// ========================================
// 瑞美云配置
// ========================================
const ruimeiyunLoading = ref(false)
const ruimeiyunLoaded = ref(false)
const ruimeiyunTesting = ref(false)
const ruimeiyunFormRef = ref(null)
const ruimeiyunForm = reactive({
base_url: '',
account: '',
private_key: '',
allowed_apis: ''
})
const ruimeiyunRules = {
base_url: [{ required: true, message: '请输入睿美云 API 地址', trigger: 'blur' }],
account: [{ required: true, message: '请输入账号', trigger: 'blur' }],
private_key: [{ required: true, message: '请输入私钥', trigger: 'blur' }]
}
async function fetchRuimeiyunConfig() {
ruimeiyunLoading.value = true
try {
const res = await api.get('/api/tool-configs', {
params: { tenant_id: tenantId, tool_code: 'ruimeiyun', size: 100 }
})
const items = res.data.items || []
// 映射配置到表单
items.forEach(item => {
if (item.config_key === 'ruimeiyun_base_url') {
ruimeiyunForm.base_url = item.config_value || ''
} else if (item.config_key === 'ruimeiyun_account') {
ruimeiyunForm.account = item.config_value || ''
} else if (item.config_key === 'ruimeiyun_private_key') {
// 加密字段显示占位符
ruimeiyunForm.private_key = item.is_encrypted ? '********' : (item.config_value || '')
} else if (item.config_key === 'ruimeiyun_allowed_apis') {
ruimeiyunForm.allowed_apis = item.config_value || ''
}
})
ruimeiyunLoaded.value = true
} catch (e) {
console.error('获取瑞美云配置失败:', e)
} finally {
ruimeiyunLoading.value = false
}
}
async function saveRuimeiyunConfig() {
await ruimeiyunFormRef.value.validate()
ruimeiyunLoading.value = true
try {
// 构建配置列表
const configs = [
{
config_type: 'external_api',
config_key: 'ruimeiyun_base_url',
config_value: ruimeiyunForm.base_url,
is_encrypted: 0,
description: '睿美云 API 地址'
},
{
config_type: 'external_api',
config_key: 'ruimeiyun_account',
config_value: ruimeiyunForm.account,
is_encrypted: 0,
description: '睿美云账号'
}
]
// 如果私钥不是占位符,则更新
if (ruimeiyunForm.private_key && ruimeiyunForm.private_key !== '********') {
configs.push({
config_type: 'external_api',
config_key: 'ruimeiyun_private_key',
config_value: ruimeiyunForm.private_key,
is_encrypted: 1,
description: '睿美云私钥'
})
}
// 如果有接口限制
if (ruimeiyunForm.allowed_apis) {
configs.push({
config_type: 'external_api',
config_key: 'ruimeiyun_allowed_apis',
config_value: ruimeiyunForm.allowed_apis,
is_encrypted: 0,
description: '允许调用的接口列表'
})
}
await api.post('/api/tool-configs/batch', {
tenant_id: tenantId,
tool_code: 'ruimeiyun',
configs
})
ElMessage.success('保存成功')
// 重新加载
ruimeiyunLoaded.value = false
fetchRuimeiyunConfig()
} catch (e) {
console.error('保存失败:', e)
} finally {
ruimeiyunLoading.value = false
}
}
async function testRuimeiyunConnection() {
ruimeiyunTesting.value = true
try {
const res = await api.get(`/api/ruimeiyun/health/${tenantId}`)
if (res.data.status === 'connected') {
ElMessage.success(`连接成功!账号: ${res.data.account}`)
} else {
ElMessage.error(`连接失败: ${res.data.message}`)
}
} catch (e) {
ElMessage.error(`测试失败: ${e.response?.data?.detail || e.message}`)
} finally {
ruimeiyunTesting.value = false
}
}
onMounted(() => {
fetchDetail()
})
@@ -304,6 +433,73 @@ onMounted(() => {
</div>
</el-tab-pane>
<!-- 瑞美云配置 Tab -->
<el-tab-pane label="瑞美云配置" name="ruimeiyun">
<div class="ruimeiyun-container" v-loading="ruimeiyunLoading">
<el-alert
type="info"
:closable="false"
style="margin-bottom: 20px"
>
<template #title>
配置租户的睿美云 TPOS 接口连接信息配置后可通过 Platform 代理调用睿美云接口
</template>
</el-alert>
<el-form
ref="ruimeiyunFormRef"
:model="ruimeiyunForm"
:rules="ruimeiyunRules"
label-width="120px"
style="max-width: 600px"
>
<el-form-item label="API 地址" prop="base_url">
<el-input
v-model="ruimeiyunForm.base_url"
placeholder="例如: https://xxx.ruimeiyun.com"
/>
<div class="form-hint">睿美云 TPOS 接口的基础地址</div>
</el-form-item>
<el-form-item label="账号" prop="account">
<el-input
v-model="ruimeiyunForm.account"
placeholder="TPOS 接口账号"
/>
</el-form-item>
<el-form-item label="私钥" prop="private_key">
<el-input
v-model="ruimeiyunForm.private_key"
type="textarea"
:rows="4"
placeholder="RSA 私钥PEM 格式)"
/>
<div class="form-hint">用于 TPOS 接口签名的 RSA 私钥将加密存储</div>
</el-form-item>
<el-form-item label="接口限制">
<el-input
v-model="ruimeiyunForm.allowed_apis"
type="textarea"
:rows="2"
placeholder='可选JSON 数组格式,例如: ["customer.search", "order.list"]'
/>
<div class="form-hint">限制租户可调用的接口留空表示允许所有接口</div>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="saveRuimeiyunConfig" :loading="ruimeiyunLoading">
保存配置
</el-button>
<el-button @click="testRuimeiyunConnection" :loading="ruimeiyunTesting">
测试连接
</el-button>
</el-form-item>
</el-form>
</div>
</el-tab-pane>
<!-- 工具配置 Tab -->
<el-tab-pane label="工具配置" name="config">
<div class="config-container" v-loading="configLoading">
@@ -506,4 +702,15 @@ onMounted(() => {
color: #909399;
font-family: monospace;
}
.ruimeiyun-container {
padding: 0 4px;
}
.form-hint {
font-size: 12px;
color: #909399;
line-height: 1.5;
margin-top: 4px;
}
</style>