Compare commits

...

64 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
cf4d6afbc8 feat: 应用配置项定义功能
All checks were successful
continuous-integration/drone/push Build is passing
- 后端: App 模型添加 config_schema 字段,支持配置项定义
- 后端: apps API 支持 config_schema 的增删改查
- 前端: 应用管理页面支持定义配置项(text/radio/select/switch类型)
- 前端: 租户订阅页面根据应用 schema 动态渲染对应表单控件
- 支持设置选项、默认值、必填等属性
2026-01-27 17:26:49 +08:00
e37466a7cc feat: 租户应用配置支持自定义参数
All checks were successful
continuous-integration/drone/push Build is passing
- 后端: TenantApp 模型添加 custom_configs 字段 (LONGTEXT)
- 后端: tenant_apps API 支持自定义配置的增删改查
- 前端: 应用订阅编辑对话框增加自定义配置编辑区域
- 支持 key-value-备注 三字段结构
- value 使用 textarea 支持超长文本(如提示词)
2026-01-27 17:15:19 +08:00
7134947c0c feat: 添加租户工具配置系统
All checks were successful
continuous-integration/drone/push Build is passing
- 新增 platform_tool_configs 表和 ToolConfig Model
- 新增工具配置 CRUD API (/api/tool-configs)
- 租户详情页添加工具配置管理 Tab
- 修复查看 Token 显示问题,添加专用获取接口
2026-01-27 11:30:02 +08:00
8e675c207d feat: 添加 /api/stats/daily 每日统计接口
All checks were successful
continuous-integration/drone/push Build is passing
2026-01-24 18:19:23 +08:00
90d10537c2 fix: 增加列表接口 size 参数限制到 1000
All checks were successful
continuous-integration/drone/push Build is passing
修复下拉列表请求 size=1000 导致 422 验证错误的问题
2026-01-24 18:01:09 +08:00
9f10b373aa chore: 触发生产环境重新部署
All checks were successful
continuous-integration/drone/push Build is passing
2026-01-24 17:59:00 +08:00
88dc9fef53 docs: 更新数据库配置文档
All checks were successful
continuous-integration/drone/push Build is passing
- 添加生产数据库 new_platform_prod 配置
- 添加 Drone API Token
- 更新 README 环境地址
2026-01-24 17:53:29 +08:00
82ac147762 feat: 生产环境使用独立数据库
All checks were successful
continuous-integration/drone/push Build is passing
- 创建生产数据库 new_platform_prod
- 配置使用 database_url_prod secret
2026-01-24 17:50:23 +08:00
cc3a6ab0c7 fix: 修复认证 422 错误和生产环境部署配置
All checks were successful
continuous-integration/drone/push Build is passing
- HTTPBearer 使用 auto_error=False 避免 422 错误
- 生产环境使用与测试相同的数据库密钥配置
2026-01-24 17:43:21 +08:00
aa3f5f6108 fix: 修复 HTTPBearer 返回 422 错误的问题
All checks were successful
continuous-integration/drone/push Build is passing
- 设置 HTTPBearer(auto_error=False) 避免验证错误
- 在 get_current_user 中手动检查并返回 401
2026-01-24 17:39:04 +08:00
9ff89f4a7d fix: 修复日志查询接口访问不存在字段的问题
All checks were successful
continuous-integration/drone/push Build is passing
- ip_address 从 context.ip 获取
- 移除不存在的 extra_data 字段
- 修复 CSV/Excel 导出中的同样问题
2026-01-24 17:25:17 +08:00
b04ee51020 chore: 触发 CI/CD 部署
All checks were successful
continuous-integration/drone/push Build is passing
2026-01-24 17:18:53 +08:00
b6b5ac61af fix: 修复 Drone 配置和 nginx 多环境支持
All checks were successful
continuous-integration/drone/push Build is passing
- 分离测试/生产环境的前端镜像构建
- nginx 配置使用 BACKEND_HOST 变量区分环境
- 生产环境添加独立的 Docker network
- 生产环境使用独立的密钥配置 (xxx_prod)
- 修复前端空白问题:确保前后端在同一 network
2026-01-24 17:15:12 +08:00
111
6c6c48cf71 feat: 新增告警、成本、配额、微信模块及缓存服务
All checks were successful
continuous-integration/drone/push Build is passing
- 新增告警模块 (alerts): 告警规则配置与触发
- 新增成本管理模块 (cost): 成本统计与分析
- 新增配额模块 (quota): 配额管理与限制
- 新增微信模块 (wechat): 微信相关功能接口
- 新增缓存服务 (cache): Redis 缓存封装
- 新增请求日志中间件 (request_logger)
- 新增异常处理和链路追踪中间件
- 更新 dashboard 前端展示
- 更新 SDK stats_client 功能
2026-01-24 16:53:47 +08:00
111
eab2533c36 fix: 修复 nginx 代理地址,解决页面空白问题
All checks were successful
continuous-integration/drone/push Build is passing
Docker 网桥地址从 172.17 变为 172.18,导致 API 代理失败
2026-01-24 15:53:32 +08:00
111
b055be1bb6 feat: 所有租户选择改为下拉列表
All checks were successful
continuous-integration/drone/push Build is passing
2026-01-24 10:31:57 +08:00
111
1c95fef01a fix: health 路由加上 /api 前缀
All checks were successful
continuous-integration/drone/push Build is passing
2026-01-24 10:24:59 +08:00
111
63e38ceb60 fix: 兼容 corp_id 字段不存在 + 添加迁移 API
All checks were successful
continuous-integration/drone/push Build is passing
2026-01-24 10:23:19 +08:00
111
ab84f6b87d feat: 企微应用租户下拉选择 + corp_id 自动填入
All checks were successful
continuous-integration/drone/push Build is passing
- 租户表增加 corp_id 字段(企业微信企业ID)
- 租户管理页面支持配置 corp_id
- 企微应用页面租户从下拉选择
- 选择租户后自动填入 corp_id 并禁用编辑
- 提示用户如租户未配置 corp_id 需先去配置
2026-01-24 10:20:02 +08:00
111
0a799ee276 fix: 完善菜单和应用验证
All checks were successful
continuous-integration/drone/push Build is passing
- 侧边栏菜单增加「应用管理」和「企微应用」
- 租户订阅页面应用选择增加前端验证
- 后端增加 app_code 存在性验证
2026-01-24 10:10:56 +08:00
111
6a93e05ec3 feat: 应用扁平化与 Token 验证 API
All checks were successful
continuous-integration/drone/push Build is passing
- 新增 /api/auth/verify 接口供外部应用验证 token
- 简化应用管理:移除 tools 字段,每个应用独立存在
- 简化应用配置:移除 allowed_tools,专注于租户订阅
- 优化 Token 展示和复制功能
2026-01-24 10:05:24 +08:00
111
c4bd7c8251 feat: 租户级企微配置改造
All checks were successful
continuous-integration/drone/push Build is passing
- 新增 platform_tenant_wechat_apps 表(租户企微应用配置)
- platform_apps 增加 require_jssdk 字段
- platform_tenant_apps 增加 wechat_app_id 关联字段
- 新增企微应用管理 API 和页面
- 应用管理页面增加 JS-SDK 开关
- 应用配置页面增加企微应用选择
2026-01-23 19:05:00 +08:00
111
f815b29c51 feat: 静态 Token 鉴权改造
All checks were successful
continuous-integration/drone/push Build is passing
- 将 token_secret 改为 access_token(长期有效)
- 移除 token_required 字段,统一使用 token 验证
- 生成链接简化为 ?tid=xxx&token=xxx 格式
- 前端移除签名验证开关,链接永久有效
2026-01-23 18:43:04 +08:00
111
39f33d7ac5 feat: 添加应用管理和生成签名链接功能
All checks were successful
continuous-integration/drone/push Build is passing
- 新增 platform_apps 表和 App 模型
- 新增应用管理页面 /apps
- 应用配置页面添加"生成链接"功能
- 支持一键生成带签名的访问 URL
2026-01-23 18:22:17 +08:00
111
2a9f62bef8 fix: use correct token fields (input_tokens + output_tokens)
All checks were successful
continuous-integration/drone/push Build is passing
2026-01-23 16:14:55 +08:00
111
b018844078 fix: add GET endpoints for stats summary and logs query
All checks were successful
continuous-integration/drone/push Build is passing
2026-01-23 16:12:18 +08:00
111
64f07a9af5 fix: use ports 3003/4003 (3002 used by Drone)
All checks were successful
continuous-integration/drone/push Build is passing
2026-01-23 16:04:40 +08:00
111
d108b168dd fix: change frontend ports to 3002/4002 (3001 used by Gitea)
Some checks failed
continuous-integration/drone/push Build is failing
2026-01-23 16:01:59 +08:00
111
4e954af55c fix: use npm install instead of npm ci (no lock file)
Some checks failed
continuous-integration/drone/push Build is failing
2026-01-23 15:56:36 +08:00
111
531d9522c5 fix: use China mirrors for pip and npm in Dockerfiles
Some checks failed
continuous-integration/drone/push Build is failing
2026-01-23 15:54:22 +08:00
111
b89d5ddee9 feat: add admin UI frontend and complete backend APIs
Some checks failed
continuous-integration/drone/push Build is failing
- Add Vue 3 frontend with Element Plus
- Implement login, dashboard, tenant management
- Add app configuration, logs viewer, stats pages
- Add user management for admins
- Update Drone CI to build and deploy frontend
- Frontend ports: 3001 (test), 4001 (prod)
2026-01-23 15:51:37 +08:00
81 changed files with 17611 additions and 136 deletions

View File

@@ -1,6 +1,6 @@
kind: pipeline
type: docker
name: build-and-deploy
name: build-and-push
trigger:
branch:
@@ -10,55 +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: 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 stop platform-backend-test || true
- docker rm platform-backend-test || true
- docker run -d --name platform-backend-test -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
- 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: build-push-frontend-test
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:develop -f deploy/Dockerfile.frontend --build-arg BACKEND_HOST=platform-backend-test .
- docker push $DOCKER_REGISTRY/ireborn/platform-frontend:develop
when:
branch:
- develop
- name: deploy-prod
# 构建并推送前端镜像(生产环境)
- name: build-push-frontend-prod
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 stop platform-backend-prod || true
- docker rm platform-backend-prod || true
- docker run -d --name platform-backend-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
- 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

2
.gitignore vendored
View File

@@ -13,7 +13,7 @@ venv/
*.swp
*.swo
*.log
logs/
/logs/
.DS_Store
Thumbs.db
dist/

137
README.md Normal file
View File

@@ -0,0 +1,137 @@
# AI 对话启动指南
> 本文档用于快速让新 AI 了解项目背景和当前状态
---
## 项目概述
这是一个多租户 AI 应用平台,包含:
| 项目 | 说明 | 技术栈 |
|------|------|--------|
| 000-platform | 统一管理平台Admin UI | Python FastAPI + Vue 3 |
| 001-tools | PHP+n8n 工具迁移 | Python FastAPI + Vue 3 |
| 011-ai-interview | AI 面试应用 | Python FastAPI + Vue 3 |
---
## 当前状态
### 已完成
- DevOps: Gitea + Drone CI/CD + Docker 自动部署
- 多租户鉴权: URL 参数签名验证 (tid, aid, ts, sign)
- 应用管理: Admin UI 可配置应用和生成签名链接
- 5 个工具迁移: 头脑风暴、高情商回复、面诊方案、客户画像、医疗合规
- AI 调用统计: 记录到 platform_ai_call_events
### 待完成
- 企业微信 JS-SDK 集成
- n8n 返回 token 统计
- 生产环境部署
---
## 关键文件位置
```
AgentWD/
├── _shared/ # 共享文档
│ ├── AI对话启动指南.md # 本文件
│ ├── 项目进度/ # 进度文档
│ │ ├── 整体进度汇总.md
│ │ ├── 000-platform进度.md
│ │ └── 001-tools迁移进度.md
│ └── 数据库/
│ └── 测试环境配置.md # 服务器/数据库信息
├── 框架/
│ └── CICD配置.md # CI/CD 说明
├── projects/
│ ├── 000-platform/ # 管理平台
│ ├── 001-tools/ # 工具集
│ └── 011-ai-interview/ # AI 面试
└── scripts/ # 数据库脚本
```
---
## 服务器信息
| 服务 | 地址 | 说明 |
|------|------|------|
| 测试服务器 | 47.107.172.23 | root / Nj861021 |
| MySQL | 47.107.71.55 | scrm_reader / ScrmReader2024Pass |
| n8n | https://n8n.ireborn.com.cn | 工作流引擎 |
| Gitea | https://git.ai.ireborn.com.cn | 代码托管 |
| Drone CI | https://ci.ai.ireborn.com.cn | 自动部署 |
---
## 环境地址
| 服务 | 测试环境 | 生产环境 |
|------|----------|----------|
| 管理平台 | http://47.107.172.23:3003 | http://47.107.172.23:4003 |
| 后端 API | http://47.107.172.23:8001 | http://47.107.172.23:9001 |
---
## 数据库配置
| 环境 | 数据库 | Drone Secret |
|------|--------|--------------|
| 测试 | `new_qiqi` | `database_url` |
| 生产 | `new_platform_prod` | `database_url_prod` |
---
## 鉴权机制
### 001-tools 租户鉴权
```
URL: https://tools.test.ai.ireborn.com.cn/brainstorm?tid=test&aid=tools
```
- `tid`: 租户ID必须
- `aid`: 应用代码(必须,默认 tools
- `ts`: 时间戳(需签名的租户必须)
- `sign`: HMAC-SHA256(tid+aid+ts, token_secret)
### 测试租户
- `test`: 免签名,直接 `?tid=test&aid=tools`
- `qiqi`: 需签名,通过 Admin UI 生成链接
---
## 开发规范
1. **分支策略**: develop → 测试环境, main → 生产环境
2. **代码提交**: 推送后自动触发 Drone CI/CD
3. **样式保留**: 001-tools 各工具保持原 PHP 样式,不统一
4. **数据库表**: 统一使用 `platform_` 前缀
---
## 常用命令
```bash
# 检查构建状态
curl "https://ci.ai.ireborn.com.cn/api/repos/admin/000-platform/builds?per_page=1"
# SSH 到服务器
ssh root@47.107.172.23 # 密码: Nj861021
# 执行 MySQL
mysql -h 47.107.71.55 -u scrm_reader -pScrmReader2024Pass new_qiqi
```
---
## 详细进度
请阅读以下文件获取更多信息:
1. `_shared/项目进度/整体进度汇总.md` - 整体架构和状态
2. `_shared/项目进度/001-tools迁移进度.md` - 工具迁移详情
3. `_shared/项目进度/000-platform进度.md` - 平台服务详情
4. `_shared/数据库/测试环境配置.md` - 环境配置详情

View File

@@ -2,6 +2,7 @@
import os
from functools import lru_cache
from pydantic_settings import BaseSettings
from typing import Optional
class Settings(BaseSettings):
@@ -14,6 +15,10 @@ class Settings(BaseSettings):
# 数据库
DATABASE_URL: str = "mysql+pymysql://scrm_reader:ScrmReader2024Pass@47.107.71.55:3306/new_qiqi"
# Redis
REDIS_URL: str = "redis://localhost:6379/0"
REDIS_PREFIX: str = "platform:"
# API Key内部服务调用
API_KEY: str = "platform_api_key_2026"
@@ -29,6 +34,10 @@ class Settings(BaseSettings):
# 配置加密密钥
CONFIG_ENCRYPT_KEY: str = "platform_config_key_32bytes!!"
# 企业微信配置
WECHAT_ACCESS_TOKEN_EXPIRE: int = 7000 # access_token缓存时间(秒)企微有效期7200秒
WECHAT_JSAPI_TICKET_EXPIRE: int = 7000 # jsapi_ticket缓存时间(秒)
class Config:
env_file = ".env"
extra = "ignore"

View File

@@ -1,9 +1,29 @@
"""平台服务入口"""
import logging
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from .config import get_settings
from .routers import stats_router, logs_router, config_router, health_router
from .routers.auth import router as auth_router
from .routers.tenants import router as tenants_router
from .routers.tenant_apps import router as tenant_apps_router
from .routers.tenant_wechat_apps import router as tenant_wechat_apps_router
from .routers.apps import router as apps_router
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)
settings = get_settings()
@@ -13,6 +33,20 @@ app = FastAPI(
description="平台基础设施服务 - 统计/日志/配置管理"
)
# 配置统一异常处理
setup_exception_handlers(app)
# 中间件按添加的反序执行,所以:
# 1. CORS 最后添加,最先执行
# 2. TraceMiddleware 在 RequestLoggerMiddleware 之后添加,这样先执行
# 3. RequestLoggerMiddleware 最先添加,最后执行(此时 trace_id 已设置)
# 请求日志中间件(自动记录到数据库)
app.add_middleware(RequestLoggerMiddleware, app_code="000-platform")
# TraceID 追踪中间件
app.add_middleware(TraceMiddleware, log_requests=True)
# CORS
app.add_middleware(
CORSMiddleware,
@@ -20,13 +54,40 @@ app.add_middleware(
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
expose_headers=["X-Trace-ID", "X-Response-Time"]
)
# 注册路由
app.include_router(health_router)
app.include_router(stats_router)
app.include_router(logs_router)
app.include_router(config_router)
app.include_router(health_router, prefix="/api")
app.include_router(auth_router, prefix="/api")
app.include_router(tenants_router, prefix="/api")
app.include_router(tenant_apps_router, prefix="/api")
app.include_router(tenant_wechat_apps_router, prefix="/api")
app.include_router(apps_router, prefix="/api")
app.include_router(stats_router, prefix="/api")
app.include_router(logs_router, prefix="/api")
app.include_router(config_router, prefix="/api")
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

@@ -0,0 +1,19 @@
"""
中间件模块
提供:
- TraceID 追踪
- 统一异常处理
- 请求日志记录
"""
from .trace import TraceMiddleware, get_trace_id, set_trace_id
from .exception_handler import setup_exception_handlers
from .request_logger import RequestLoggerMiddleware
__all__ = [
"TraceMiddleware",
"get_trace_id",
"set_trace_id",
"setup_exception_handlers",
"RequestLoggerMiddleware"
]

View File

@@ -0,0 +1,128 @@
"""
统一异常处理
捕获所有异常,返回统一格式的错误响应,包含 TraceID。
"""
import logging
import traceback
from typing import Union
from fastapi import FastAPI, Request, HTTPException
from fastapi.responses import JSONResponse
from fastapi.exceptions import RequestValidationError
from starlette.exceptions import HTTPException as StarletteHTTPException
from .trace import get_trace_id
logger = logging.getLogger(__name__)
class ErrorCode:
"""错误码常量"""
BAD_REQUEST = "BAD_REQUEST"
UNAUTHORIZED = "UNAUTHORIZED"
FORBIDDEN = "FORBIDDEN"
NOT_FOUND = "NOT_FOUND"
VALIDATION_ERROR = "VALIDATION_ERROR"
RATE_LIMITED = "RATE_LIMITED"
INTERNAL_ERROR = "INTERNAL_ERROR"
SERVICE_UNAVAILABLE = "SERVICE_UNAVAILABLE"
GATEWAY_ERROR = "GATEWAY_ERROR"
STATUS_TO_ERROR_CODE = {
400: ErrorCode.BAD_REQUEST,
401: ErrorCode.UNAUTHORIZED,
403: ErrorCode.FORBIDDEN,
404: ErrorCode.NOT_FOUND,
422: ErrorCode.VALIDATION_ERROR,
429: ErrorCode.RATE_LIMITED,
500: ErrorCode.INTERNAL_ERROR,
502: ErrorCode.GATEWAY_ERROR,
503: ErrorCode.SERVICE_UNAVAILABLE,
}
def create_error_response(
status_code: int,
code: str,
message: str,
trace_id: str = None,
details: dict = None
) -> JSONResponse:
"""创建统一格式的错误响应"""
if trace_id is None:
trace_id = get_trace_id()
error_body = {
"code": code,
"message": message,
"trace_id": trace_id
}
if details:
error_body["details"] = details
return JSONResponse(
status_code=status_code,
content={"success": False, "error": error_body},
headers={"X-Trace-ID": trace_id}
)
async def http_exception_handler(request: Request, exc: Union[HTTPException, StarletteHTTPException]):
"""处理 HTTP 异常"""
trace_id = get_trace_id()
status_code = exc.status_code
error_code = STATUS_TO_ERROR_CODE.get(status_code, ErrorCode.INTERNAL_ERROR)
message = exc.detail if isinstance(exc.detail, str) else str(exc.detail)
logger.warning(f"[{trace_id}] HTTP {status_code}: {message}")
return create_error_response(
status_code=status_code,
code=error_code,
message=message,
trace_id=trace_id
)
async def validation_exception_handler(request: Request, exc: RequestValidationError):
"""处理请求验证错误"""
trace_id = get_trace_id()
errors = exc.errors()
error_messages = [f"{'.'.join(str(l) for l in e['loc'])}: {e['msg']}" for e in errors]
logger.warning(f"[{trace_id}] 验证错误: {error_messages}")
return create_error_response(
status_code=422,
code=ErrorCode.VALIDATION_ERROR,
message="请求参数验证失败",
trace_id=trace_id,
details={"validation_errors": error_messages}
)
async def generic_exception_handler(request: Request, exc: Exception):
"""处理所有未捕获的异常"""
trace_id = get_trace_id()
logger.error(f"[{trace_id}] 未捕获异常: {type(exc).__name__}: {exc}")
logger.error(f"[{trace_id}] 堆栈:\n{traceback.format_exc()}")
return create_error_response(
status_code=500,
code=ErrorCode.INTERNAL_ERROR,
message="服务器内部错误,请稍后重试",
trace_id=trace_id
)
def setup_exception_handlers(app: FastAPI):
"""配置 FastAPI 应用的异常处理器"""
app.add_exception_handler(HTTPException, http_exception_handler)
app.add_exception_handler(StarletteHTTPException, http_exception_handler)
app.add_exception_handler(RequestValidationError, validation_exception_handler)
app.add_exception_handler(Exception, generic_exception_handler)
logger.info("异常处理器已配置")

View File

@@ -0,0 +1,190 @@
"""
请求日志中间件
自动将所有请求记录到数据库 platform_logs 表
"""
import time
import logging
from typing import Optional, Set
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.requests import Request
from starlette.responses import Response
from .trace import get_trace_id
from ..database import SessionLocal
from ..models.logs import PlatformLog
logger = logging.getLogger(__name__)
class RequestLoggerMiddleware(BaseHTTPMiddleware):
"""请求日志中间件
自动记录所有请求到数据库,便于后续查询和分析
使用示例:
app.add_middleware(RequestLoggerMiddleware, app_code="000-platform")
"""
# 默认排除的路径(不记录这些请求)
DEFAULT_EXCLUDE_PATHS: Set[str] = {
"/",
"/docs",
"/redoc",
"/openapi.json",
"/api/health",
"/api/health/",
"/favicon.ico",
}
def __init__(
self,
app,
app_code: str = "platform",
exclude_paths: Optional[Set[str]] = None,
log_request_body: bool = False,
log_response_body: bool = False,
max_body_length: int = 1000
):
"""初始化中间件
Args:
app: FastAPI应用
app_code: 应用代码,记录到日志中
exclude_paths: 排除的路径集合,这些路径不记录日志
log_request_body: 是否记录请求体
log_response_body: 是否记录响应体
max_body_length: 记录体的最大长度
"""
super().__init__(app)
self.app_code = app_code
self.exclude_paths = exclude_paths or self.DEFAULT_EXCLUDE_PATHS
self.log_request_body = log_request_body
self.log_response_body = log_response_body
self.max_body_length = max_body_length
async def dispatch(self, request: Request, call_next) -> Response:
path = request.url.path
# 检查是否排除
if self._should_exclude(path):
return await call_next(request)
trace_id = get_trace_id()
method = request.method
start_time = time.time()
# 获取客户端IP
client_ip = self._get_client_ip(request)
# 获取租户ID从查询参数
tenant_id = request.query_params.get("tid") or request.query_params.get("tenant_id")
# 执行请求
response = None
error_message = None
status_code = 500
try:
response = await call_next(request)
status_code = response.status_code
except Exception as e:
error_message = str(e)
raise
finally:
duration_ms = int((time.time() - start_time) * 1000)
# 异步写入数据库(不阻塞响应)
try:
self._save_log(
trace_id=trace_id,
method=method,
path=path,
status_code=status_code,
duration_ms=duration_ms,
ip_address=client_ip,
tenant_id=tenant_id,
error_message=error_message
)
except Exception as e:
logger.error(f"Failed to save request log: {e}")
return response
def _should_exclude(self, path: str) -> bool:
"""检查路径是否应排除"""
# 精确匹配
if path in self.exclude_paths:
return True
# 前缀匹配(静态文件等)
exclude_prefixes = ["/static/", "/assets/", "/_next/"]
for prefix in exclude_prefixes:
if path.startswith(prefix):
return True
return False
def _get_client_ip(self, request: Request) -> str:
"""获取客户端真实IP"""
# 优先从代理头获取
forwarded = request.headers.get("X-Forwarded-For")
if forwarded:
return forwarded.split(",")[0].strip()
real_ip = request.headers.get("X-Real-IP")
if real_ip:
return real_ip
# 直连IP
if request.client:
return request.client.host
return "unknown"
def _save_log(
self,
trace_id: str,
method: str,
path: str,
status_code: int,
duration_ms: int,
ip_address: str,
tenant_id: Optional[str] = None,
error_message: Optional[str] = None
):
"""保存日志到数据库"""
from datetime import datetime
# 使用独立的数据库会话
db = SessionLocal()
try:
# 转换 tenant_id 为整数(如果是数字字符串)
tenant_id_int = None
if tenant_id:
try:
tenant_id_int = int(tenant_id)
except (ValueError, TypeError):
tenant_id_int = None
log_entry = PlatformLog(
log_type="request",
level="error" if status_code >= 500 else ("warn" if status_code >= 400 else "info"),
app_code=self.app_code,
tenant_id=tenant_id_int,
trace_id=trace_id,
message=f"{method} {path}" + (f" - {error_message}" if error_message else ""),
path=path,
method=method,
status_code=status_code,
duration_ms=duration_ms,
log_time=datetime.now(), # 必须设置 log_time
context={"ip": ip_address} # ip_address 放到 context 中
)
db.add(log_entry)
db.commit()
except Exception as e:
logger.error(f"Database error saving log: {e}")
db.rollback()
finally:
db.close()

View File

@@ -0,0 +1,114 @@
"""
TraceID 追踪中间件
为每个请求生成唯一的 TraceID用于日志追踪和问题排查。
功能:
- 自动生成 TraceID或从请求头获取
- 注入到响应头 X-Trace-ID
- 提供上下文变量供日志使用
- 支持请求耗时统计
"""
import time
import uuid
import logging
from contextvars import ContextVar
from typing import Optional
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.requests import Request
from starlette.responses import Response
logger = logging.getLogger(__name__)
# 上下文变量存储当前请求的 TraceID
_trace_id_var: ContextVar[Optional[str]] = ContextVar("trace_id", default=None)
# 请求头名称
TRACE_ID_HEADER = "X-Trace-ID"
REQUEST_ID_HEADER = "X-Request-ID"
def get_trace_id() -> str:
"""获取当前请求的 TraceID"""
trace_id = _trace_id_var.get()
return trace_id if trace_id else "no-trace"
def set_trace_id(trace_id: str) -> None:
"""设置当前请求的 TraceID"""
_trace_id_var.set(trace_id)
def generate_trace_id() -> str:
"""生成新的 TraceID格式: 时间戳-随机8位"""
timestamp = int(time.time())
random_part = uuid.uuid4().hex[:8]
return f"{timestamp}-{random_part}"
class TraceMiddleware(BaseHTTPMiddleware):
"""TraceID 追踪中间件"""
def __init__(self, app, log_requests: bool = True):
super().__init__(app)
self.log_requests = log_requests
async def dispatch(self, request: Request, call_next) -> Response:
# 从请求头获取 TraceID或生成新的
trace_id = (
request.headers.get(TRACE_ID_HEADER) or
request.headers.get(REQUEST_ID_HEADER) or
generate_trace_id()
)
set_trace_id(trace_id)
start_time = time.time()
method = request.method
path = request.url.path
if self.log_requests:
logger.info(f"[{trace_id}] --> {method} {path}")
try:
response = await call_next(request)
duration_ms = int((time.time() - start_time) * 1000)
response.headers[TRACE_ID_HEADER] = trace_id
response.headers["X-Response-Time"] = f"{duration_ms}ms"
if self.log_requests:
logger.info(f"[{trace_id}] <-- {response.status_code} ({duration_ms}ms)")
return response
except Exception as e:
duration_ms = int((time.time() - start_time) * 1000)
logger.error(f"[{trace_id}] !!! 请求异常: {e} ({duration_ms}ms)")
raise
class TraceLogFilter(logging.Filter):
"""日志过滤器:自动添加 TraceID"""
def filter(self, record):
record.trace_id = get_trace_id()
return True
def setup_logging(level: int = logging.INFO, include_trace: bool = True):
"""配置日志格式"""
if include_trace:
format_str = "%(asctime)s [%(trace_id)s] %(levelname)s %(name)s: %(message)s"
else:
format_str = "%(asctime)s %(levelname)s %(name)s: %(message)s"
handler = logging.StreamHandler()
handler.setFormatter(logging.Formatter(format_str, datefmt="%Y-%m-%d %H:%M:%S"))
if include_trace:
handler.addFilter(TraceLogFilter())
root_logger = logging.getLogger()
root_logger.setLevel(level)
root_logger.handlers = [handler]

View File

@@ -1,13 +1,35 @@
"""数据模型"""
from .tenant import Tenant, Subscription, Config
from .tenant_app import TenantApp
from .tenant_wechat_app import TenantWechatApp
from .tool_config import ToolConfig
from .app import App
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",
"Subscription",
"Config",
"TenantApp",
"TenantWechatApp",
"ToolConfig",
"App",
"AICallEvent",
"TenantUsageDaily",
"PlatformLog"
"PlatformLog",
"AlertRule",
"AlertRecord",
"NotificationChannel",
"ModelPricing",
"TenantBilling",
"ScheduledTask",
"TaskLog",
"ScriptVar",
"Secret",
"TaskNotifyChannel"
]

108
backend/app/models/alert.py Normal file
View File

@@ -0,0 +1,108 @@
"""告警相关模型"""
from datetime import datetime
from sqlalchemy import Column, Integer, BigInteger, String, Text, Enum, SmallInteger, JSON, TIMESTAMP
from ..database import Base
class AlertRule(Base):
"""告警规则表"""
__tablename__ = "platform_alert_rules"
id = Column(Integer, primary_key=True, autoincrement=True)
name = Column(String(100), nullable=False) # 规则名称
description = Column(Text) # 规则描述
# 规则类型
rule_type = Column(Enum(
'error_rate', # 错误率告警
'call_count', # 调用次数告警
'token_usage', # Token使用量告警
'cost_threshold', # 费用阈值告警
'latency', # 延迟告警
'custom' # 自定义告警
), nullable=False)
# 作用范围
scope_type = Column(Enum('global', 'tenant', 'app'), default='global') # 作用范围类型
scope_value = Column(String(100)) # 作用范围值如租户ID或应用代码
# 告警条件
condition = Column(JSON, nullable=False) # 告警条件配置
# 示例: {"metric": "error_count", "operator": ">", "threshold": 10, "window": "5m"}
# 通知配置
notification_channels = Column(JSON) # 通知渠道列表
# 示例: [{"type": "wechat_bot", "webhook": "https://..."}, {"type": "email", "to": ["a@b.com"]}]
# 告警限制
cooldown_minutes = Column(Integer, default=30) # 冷却时间(分钟),避免重复告警
max_alerts_per_day = Column(Integer, default=10) # 每天最大告警次数
# 状态
status = Column(SmallInteger, default=1) # 0-禁用 1-启用
priority = Column(Enum('low', 'medium', 'high', 'critical'), default='medium') # 优先级
created_at = Column(TIMESTAMP, default=datetime.now)
updated_at = Column(TIMESTAMP, default=datetime.now, onupdate=datetime.now)
class AlertRecord(Base):
"""告警记录表"""
__tablename__ = "platform_alert_records"
id = Column(BigInteger, primary_key=True, autoincrement=True)
rule_id = Column(Integer, nullable=False, index=True) # 关联的规则ID
rule_name = Column(String(100)) # 规则名称(冗余,便于查询)
# 告警信息
alert_type = Column(String(50), nullable=False) # 告警类型
severity = Column(Enum('info', 'warning', 'error', 'critical'), default='warning') # 严重程度
title = Column(String(200), nullable=False) # 告警标题
message = Column(Text) # 告警详情
# 上下文
tenant_id = Column(String(50), index=True) # 相关租户
app_code = Column(String(50)) # 相关应用
metric_value = Column(String(100)) # 触发告警的指标值
threshold_value = Column(String(100)) # 阈值
# 通知状态
notification_status = Column(Enum('pending', 'sent', 'failed', 'skipped'), default='pending')
notification_result = Column(JSON) # 通知结果
notified_at = Column(TIMESTAMP) # 通知时间
# 处理状态
status = Column(Enum('active', 'acknowledged', 'resolved', 'ignored'), default='active')
acknowledged_by = Column(String(100)) # 确认人
acknowledged_at = Column(TIMESTAMP) # 确认时间
resolved_at = Column(TIMESTAMP) # 解决时间
created_at = Column(TIMESTAMP, default=datetime.now, index=True)
class NotificationChannel(Base):
"""通知渠道配置表"""
__tablename__ = "platform_notification_channels"
id = Column(Integer, primary_key=True, autoincrement=True)
name = Column(String(100), nullable=False) # 渠道名称
channel_type = Column(Enum(
'wechat_bot', # 企微机器人
'email', # 邮件
'sms', # 短信
'webhook', # Webhook
'dingtalk' # 钉钉
), nullable=False)
# 渠道配置
config = Column(JSON, nullable=False)
# wechat_bot: {"webhook": "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=xxx"}
# email: {"smtp_host": "...", "smtp_port": 465, "username": "...", "password_encrypted": "..."}
# webhook: {"url": "https://...", "method": "POST", "headers": {...}}
# 状态
status = Column(SmallInteger, default=1) # 0-禁用 1-启用
created_at = Column(TIMESTAMP, default=datetime.now)
updated_at = Column(TIMESTAMP, default=datetime.now, onupdate=datetime.now)

31
backend/app/models/app.py Normal file
View File

@@ -0,0 +1,31 @@
"""应用定义模型"""
from datetime import datetime
from sqlalchemy import Column, Integer, String, Text, SmallInteger, TIMESTAMP
from ..database import Base
class App(Base):
"""应用定义表 - 定义可供租户使用的应用"""
__tablename__ = "platform_apps"
id = Column(Integer, primary_key=True, autoincrement=True)
app_code = Column(String(50), nullable=False, unique=True) # 唯一标识,如 tools
app_name = Column(String(100), nullable=False) # 显示名称
base_url = Column(String(500)) # 基础URL如 https://tools.test.ai.ireborn.com.cn
description = Column(Text) # 应用描述
# 应用下的工具/功能列表JSON 数组)
# [{"code": "brainstorm", "name": "头脑风暴", "path": "/brainstorm"}, ...]
tools = Column(Text)
# 配置项定义JSON 数组)- 定义租户可配置的参数
# [{"key": "industry", "label": "行业类型", "type": "radio", "options": [...], "default": "...", "required": false}, ...]
# type: text(文本) | radio(单选) | select(下拉多选) | switch(开关)
config_schema = Column(Text)
# 是否需要企微JS-SDK
require_jssdk = Column(SmallInteger, default=0) # 0-不需要 1-需要
status = Column(SmallInteger, default=1) # 0-禁用 1-启用
created_at = Column(TIMESTAMP, default=datetime.now)
updated_at = Column(TIMESTAMP, default=datetime.now, onupdate=datetime.now)

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,70 @@
"""费用计算相关模型"""
from datetime import datetime
from decimal import Decimal
from sqlalchemy import Column, Integer, String, Text, DECIMAL, SmallInteger, JSON, TIMESTAMP
from ..database import Base
class ModelPricing(Base):
"""模型价格配置表"""
__tablename__ = "platform_model_pricing"
id = Column(Integer, primary_key=True, autoincrement=True)
# 模型标识
model_name = Column(String(100), nullable=False, unique=True) # 模型名称,如 gpt-4, claude-3-opus
provider = Column(String(50)) # 提供商,如 openai, anthropic, 4sapi
display_name = Column(String(100)) # 显示名称
# 价格配置(单位:元/1K tokens
input_price_per_1k = Column(DECIMAL(10, 6), default=0) # 输入价格
output_price_per_1k = Column(DECIMAL(10, 6), default=0) # 输出价格
# 或固定价格(每次调用)
fixed_price_per_call = Column(DECIMAL(10, 6), default=0)
# 计费方式
pricing_type = Column(String(20), default='token') # token / call / hybrid
# 备注
description = Column(Text)
status = Column(SmallInteger, default=1) # 0-禁用 1-启用
created_at = Column(TIMESTAMP, default=datetime.now)
updated_at = Column(TIMESTAMP, default=datetime.now, onupdate=datetime.now)
class TenantBilling(Base):
"""租户账单表(月度汇总)"""
__tablename__ = "platform_tenant_billing"
id = Column(Integer, primary_key=True, autoincrement=True)
tenant_id = Column(String(50), nullable=False, index=True)
billing_month = Column(String(7), nullable=False) # 格式: YYYY-MM
# 使用量统计
total_calls = Column(Integer, default=0) # 总调用次数
total_input_tokens = Column(Integer, default=0) # 总输入token
total_output_tokens = Column(Integer, default=0) # 总输出token
# 费用统计
total_cost = Column(DECIMAL(12, 4), default=0) # 总费用
# 按模型分类的费用明细
cost_by_model = Column(JSON) # {"gpt-4": 10.5, "claude-3": 5.2}
# 按应用分类的费用明细
cost_by_app = Column(JSON) # {"tools": 8.0, "interview": 7.7}
# 状态
status = Column(String(20), default='pending') # pending / confirmed / paid
created_at = Column(TIMESTAMP, default=datetime.now)
updated_at = Column(TIMESTAMP, default=datetime.now, onupdate=datetime.now)
class Config:
# 联合唯一索引
__table_args__ = (
{'mysql_charset': 'utf8mb4'}
)

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

@@ -11,6 +11,7 @@ class Tenant(Base):
id = Column(BigInteger, primary_key=True, autoincrement=True)
code = Column(String(50), unique=True, nullable=False)
name = Column(String(100), nullable=False)
corp_id = Column(String(100)) # 企业微信企业ID
contact_info = Column(JSON)
status = Column(Enum('active', 'expired', 'trial'), default='active')
expired_at = Column(Date)

View File

@@ -0,0 +1,32 @@
"""租户应用配置模型"""
from datetime import datetime
from sqlalchemy import Column, BigInteger, Integer, String, Text, SmallInteger, TIMESTAMP
from ..database import Base
class TenantApp(Base):
"""租户应用配置表"""
__tablename__ = "platform_tenant_apps"
id = Column(Integer, primary_key=True, autoincrement=True)
tenant_id = Column(String(50), nullable=False)
app_code = Column(String(50), nullable=False, default='tools')
app_name = Column(String(100))
# 企业微信配置(关联 platform_tenant_wechat_apps
wechat_app_id = Column(Integer) # 关联的企微应用ID
# 鉴权配置
access_token = Column(String(64)) # 访问令牌(长期有效)
allowed_origins = Column(Text) # JSON 数组
# 功能权限
allowed_tools = Column(Text) # JSON 数组
# 自定义配置JSON 数组)
# [{"key": "industry", "value": "medical_beauty", "remark": "医美行业"}, ...]
custom_configs = Column(Text)
status = Column(SmallInteger, default=1)
created_at = Column(TIMESTAMP, default=datetime.now)
updated_at = Column(TIMESTAMP, default=datetime.now, onupdate=datetime.now)

View File

@@ -0,0 +1,23 @@
"""租户企业微信应用配置模型"""
from datetime import datetime
from sqlalchemy import Column, Integer, String, Text, SmallInteger, TIMESTAMP
from ..database import Base
class TenantWechatApp(Base):
"""租户企业微信应用配置表
一个租户可以配置多个企微应用,供不同的平台应用关联使用
"""
__tablename__ = "platform_tenant_wechat_apps"
id = Column(Integer, primary_key=True, autoincrement=True)
tenant_id = Column(String(50), nullable=False, index=True)
name = Column(String(100), nullable=False) # 应用名称,如"工具集应用"
corp_id = Column(String(100), nullable=False) # 企业ID
agent_id = Column(String(50), nullable=False) # 应用AgentId
secret_encrypted = Column(Text) # 加密的Secret
status = Column(SmallInteger, default=1) # 0-禁用 1-启用
created_at = Column(TIMESTAMP, default=datetime.now)
updated_at = Column(TIMESTAMP, default=datetime.now, onupdate=datetime.now)

View File

@@ -0,0 +1,21 @@
"""租户工具配置模型"""
from datetime import datetime
from sqlalchemy import Column, Integer, String, Text, SmallInteger, TIMESTAMP
from ..database import Base
class ToolConfig(Base):
"""租户工具配置表"""
__tablename__ = "platform_tool_configs"
id = Column(Integer, primary_key=True, autoincrement=True)
tenant_id = Column(String(50), nullable=False, comment="租户ID")
tool_code = Column(String(50), nullable=True, comment="工具代码NULL 表示租户级共享配置)")
config_type = Column(String(30), nullable=False, comment="配置类型datasource / jssdk / webhook / params")
config_key = Column(String(100), nullable=False, comment="配置键名")
config_value = Column(Text, comment="配置值(明文或加密)")
is_encrypted = Column(SmallInteger, default=0, comment="是否加密存储")
description = Column(String(255), comment="配置说明")
status = Column(SmallInteger, default=1)
created_at = Column(TIMESTAMP, default=datetime.now)
updated_at = Column(TIMESTAMP, default=datetime.now, onupdate=datetime.now)

View File

@@ -0,0 +1,19 @@
"""用户模型"""
from datetime import datetime
from sqlalchemy import Column, BigInteger, String, Enum, TIMESTAMP, SmallInteger
from ..database import Base
class User(Base):
"""用户表"""
__tablename__ = "platform_users"
id = Column(BigInteger, primary_key=True, autoincrement=True)
username = Column(String(50), unique=True, nullable=False)
password_hash = Column(String(255), nullable=False)
nickname = Column(String(100))
role = Column(Enum('admin', 'operator', 'viewer'), default='viewer')
status = Column(SmallInteger, default=1) # 1=启用, 0=禁用
last_login_at = Column(TIMESTAMP, nullable=True)
created_at = Column(TIMESTAMP, default=datetime.now)
updated_at = Column(TIMESTAMP, default=datetime.now, onupdate=datetime.now)

View File

@@ -0,0 +1,430 @@
"""告警管理路由"""
from typing import Optional, List
from datetime import datetime, timedelta
from fastapi import APIRouter, Depends, HTTPException, Query, BackgroundTasks
from pydantic import BaseModel
from sqlalchemy.orm import Session
from sqlalchemy import desc, func
from ..database import get_db
from ..models.alert import AlertRule, AlertRecord, NotificationChannel
from ..services.alert import AlertService
from .auth import get_current_user, require_operator
from ..models.user import User
router = APIRouter(prefix="/alerts", tags=["告警管理"])
# ============= Schemas =============
class AlertRuleCreate(BaseModel):
name: str
description: Optional[str] = None
rule_type: str
scope_type: str = "global"
scope_value: Optional[str] = None
condition: dict
notification_channels: Optional[List[dict]] = None
cooldown_minutes: int = 30
max_alerts_per_day: int = 10
priority: str = "medium"
class AlertRuleUpdate(BaseModel):
name: Optional[str] = None
description: Optional[str] = None
condition: Optional[dict] = None
notification_channels: Optional[List[dict]] = None
cooldown_minutes: Optional[int] = None
max_alerts_per_day: Optional[int] = None
priority: Optional[str] = None
status: Optional[int] = None
class NotificationChannelCreate(BaseModel):
name: str
channel_type: str
config: dict
class NotificationChannelUpdate(BaseModel):
name: Optional[str] = None
config: Optional[dict] = None
status: Optional[int] = None
# ============= Alert Rules API =============
@router.get("/rules")
async def list_alert_rules(
page: int = Query(1, ge=1),
size: int = Query(20, ge=1, le=100),
rule_type: Optional[str] = None,
status: Optional[int] = None,
user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""获取告警规则列表"""
query = db.query(AlertRule)
if rule_type:
query = query.filter(AlertRule.rule_type == rule_type)
if status is not None:
query = query.filter(AlertRule.status == status)
total = query.count()
rules = query.order_by(desc(AlertRule.created_at)).offset((page - 1) * size).limit(size).all()
return {
"total": total,
"page": page,
"size": size,
"items": [format_rule(r) for r in rules]
}
@router.get("/rules/{rule_id}")
async def get_alert_rule(
rule_id: int,
user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""获取告警规则详情"""
rule = db.query(AlertRule).filter(AlertRule.id == rule_id).first()
if not rule:
raise HTTPException(status_code=404, detail="告警规则不存在")
return format_rule(rule)
@router.post("/rules")
async def create_alert_rule(
data: AlertRuleCreate,
user: User = Depends(require_operator),
db: Session = Depends(get_db)
):
"""创建告警规则"""
rule = AlertRule(
name=data.name,
description=data.description,
rule_type=data.rule_type,
scope_type=data.scope_type,
scope_value=data.scope_value,
condition=data.condition,
notification_channels=data.notification_channels,
cooldown_minutes=data.cooldown_minutes,
max_alerts_per_day=data.max_alerts_per_day,
priority=data.priority,
status=1
)
db.add(rule)
db.commit()
db.refresh(rule)
return {"success": True, "id": rule.id}
@router.put("/rules/{rule_id}")
async def update_alert_rule(
rule_id: int,
data: AlertRuleUpdate,
user: User = Depends(require_operator),
db: Session = Depends(get_db)
):
"""更新告警规则"""
rule = db.query(AlertRule).filter(AlertRule.id == rule_id).first()
if not rule:
raise HTTPException(status_code=404, detail="告警规则不存在")
update_data = data.model_dump(exclude_unset=True)
for key, value in update_data.items():
setattr(rule, key, value)
db.commit()
return {"success": True}
@router.delete("/rules/{rule_id}")
async def delete_alert_rule(
rule_id: int,
user: User = Depends(require_operator),
db: Session = Depends(get_db)
):
"""删除告警规则"""
rule = db.query(AlertRule).filter(AlertRule.id == rule_id).first()
if not rule:
raise HTTPException(status_code=404, detail="告警规则不存在")
db.delete(rule)
db.commit()
return {"success": True}
# ============= Alert Records API =============
@router.get("/records")
async def list_alert_records(
page: int = Query(1, ge=1),
size: int = Query(20, ge=1, le=100),
status: Optional[str] = None,
severity: Optional[str] = None,
alert_type: Optional[str] = None,
tenant_id: Optional[str] = None,
start_date: Optional[str] = None,
end_date: Optional[str] = None,
user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""获取告警记录列表"""
query = db.query(AlertRecord)
if status:
query = query.filter(AlertRecord.status == status)
if severity:
query = query.filter(AlertRecord.severity == severity)
if alert_type:
query = query.filter(AlertRecord.alert_type == alert_type)
if tenant_id:
query = query.filter(AlertRecord.tenant_id == tenant_id)
if start_date:
query = query.filter(AlertRecord.created_at >= start_date)
if end_date:
query = query.filter(AlertRecord.created_at <= end_date + " 23:59:59")
total = query.count()
records = query.order_by(desc(AlertRecord.created_at)).offset((page - 1) * size).limit(size).all()
return {
"total": total,
"page": page,
"size": size,
"items": [format_record(r) for r in records]
}
@router.get("/records/summary")
async def get_alert_summary(
user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""获取告警摘要统计"""
today = datetime.now().date()
week_start = today - timedelta(days=7)
# 今日告警数
today_count = db.query(func.count(AlertRecord.id)).filter(
func.date(AlertRecord.created_at) == today
).scalar()
# 本周告警数
week_count = db.query(func.count(AlertRecord.id)).filter(
func.date(AlertRecord.created_at) >= week_start
).scalar()
# 活跃告警数
active_count = db.query(func.count(AlertRecord.id)).filter(
AlertRecord.status == 'active'
).scalar()
# 按严重程度统计
severity_stats = db.query(
AlertRecord.severity,
func.count(AlertRecord.id)
).filter(
func.date(AlertRecord.created_at) >= week_start
).group_by(AlertRecord.severity).all()
return {
"today_count": today_count,
"week_count": week_count,
"active_count": active_count,
"by_severity": {s: c for s, c in severity_stats}
}
@router.get("/records/{record_id}")
async def get_alert_record(
record_id: int,
user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""获取告警记录详情"""
record = db.query(AlertRecord).filter(AlertRecord.id == record_id).first()
if not record:
raise HTTPException(status_code=404, detail="告警记录不存在")
return format_record(record)
@router.post("/records/{record_id}/acknowledge")
async def acknowledge_alert(
record_id: int,
user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""确认告警"""
service = AlertService(db)
record = service.acknowledge_alert(record_id, user.username)
if not record:
raise HTTPException(status_code=404, detail="告警记录不存在")
return {"success": True}
@router.post("/records/{record_id}/resolve")
async def resolve_alert(
record_id: int,
user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""解决告警"""
service = AlertService(db)
record = service.resolve_alert(record_id)
if not record:
raise HTTPException(status_code=404, detail="告警记录不存在")
return {"success": True}
# ============= Check Alerts API =============
@router.post("/check")
async def trigger_alert_check(
background_tasks: BackgroundTasks,
user: User = Depends(require_operator),
db: Session = Depends(get_db)
):
"""手动触发告警检查"""
service = AlertService(db)
alerts = await service.check_all_rules()
# 异步发送通知
for alert in alerts:
rule = db.query(AlertRule).filter(AlertRule.id == alert.rule_id).first()
if rule:
background_tasks.add_task(service.send_notification, alert, rule)
return {
"success": True,
"triggered_count": len(alerts),
"alerts": [format_record(a) for a in alerts]
}
# ============= Notification Channels API =============
@router.get("/channels")
async def list_notification_channels(
user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""获取通知渠道列表"""
channels = db.query(NotificationChannel).order_by(desc(NotificationChannel.created_at)).all()
return [format_channel(c) for c in channels]
@router.post("/channels")
async def create_notification_channel(
data: NotificationChannelCreate,
user: User = Depends(require_operator),
db: Session = Depends(get_db)
):
"""创建通知渠道"""
channel = NotificationChannel(
name=data.name,
channel_type=data.channel_type,
config=data.config,
status=1
)
db.add(channel)
db.commit()
db.refresh(channel)
return {"success": True, "id": channel.id}
@router.put("/channels/{channel_id}")
async def update_notification_channel(
channel_id: int,
data: NotificationChannelUpdate,
user: User = Depends(require_operator),
db: Session = Depends(get_db)
):
"""更新通知渠道"""
channel = db.query(NotificationChannel).filter(NotificationChannel.id == channel_id).first()
if not channel:
raise HTTPException(status_code=404, detail="通知渠道不存在")
update_data = data.model_dump(exclude_unset=True)
for key, value in update_data.items():
setattr(channel, key, value)
db.commit()
return {"success": True}
@router.delete("/channels/{channel_id}")
async def delete_notification_channel(
channel_id: int,
user: User = Depends(require_operator),
db: Session = Depends(get_db)
):
"""删除通知渠道"""
channel = db.query(NotificationChannel).filter(NotificationChannel.id == channel_id).first()
if not channel:
raise HTTPException(status_code=404, detail="通知渠道不存在")
db.delete(channel)
db.commit()
return {"success": True}
# ============= Helper Functions =============
def format_rule(rule: AlertRule) -> dict:
return {
"id": rule.id,
"name": rule.name,
"description": rule.description,
"rule_type": rule.rule_type,
"scope_type": rule.scope_type,
"scope_value": rule.scope_value,
"condition": rule.condition,
"notification_channels": rule.notification_channels,
"cooldown_minutes": rule.cooldown_minutes,
"max_alerts_per_day": rule.max_alerts_per_day,
"priority": rule.priority,
"status": rule.status,
"created_at": rule.created_at,
"updated_at": rule.updated_at
}
def format_record(record: AlertRecord) -> dict:
return {
"id": record.id,
"rule_id": record.rule_id,
"rule_name": record.rule_name,
"alert_type": record.alert_type,
"severity": record.severity,
"title": record.title,
"message": record.message,
"tenant_id": record.tenant_id,
"app_code": record.app_code,
"metric_value": record.metric_value,
"threshold_value": record.threshold_value,
"notification_status": record.notification_status,
"status": record.status,
"acknowledged_by": record.acknowledged_by,
"acknowledged_at": record.acknowledged_at,
"resolved_at": record.resolved_at,
"created_at": record.created_at
}
def format_channel(channel: NotificationChannel) -> dict:
return {
"id": channel.id,
"name": channel.name,
"channel_type": channel.channel_type,
"config": channel.config,
"status": channel.status,
"created_at": channel.created_at,
"updated_at": channel.updated_at
}

313
backend/app/routers/apps.py Normal file
View File

@@ -0,0 +1,313 @@
"""应用管理路由"""
import json
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel
from typing import Optional, List
from sqlalchemy.orm import Session
from ..database import get_db
from ..models.app import App
from ..models.tenant_app import TenantApp
from .auth import get_current_user, require_operator
from ..models.user import User
router = APIRouter(prefix="/apps", tags=["应用管理"])
# ============ Schemas ============
class ToolItem(BaseModel):
"""工具项"""
code: str
name: str
path: str
class ConfigSchemaItem(BaseModel):
"""配置项定义"""
key: str # 配置键
label: str # 显示标签
type: str # text | radio | select | switch
options: Optional[List[str]] = None # radio/select 的选项值
option_labels: Optional[dict] = None # 选项显示名称 {"value": "显示名"}
default: Optional[str] = None # 默认值
placeholder: Optional[str] = None # 输入提示text类型
required: bool = False # 是否必填
class AppCreate(BaseModel):
"""创建应用"""
app_code: str
app_name: str
base_url: Optional[str] = None
description: Optional[str] = None
tools: Optional[List[ToolItem]] = None
config_schema: Optional[List[ConfigSchemaItem]] = None
require_jssdk: bool = False
class AppUpdate(BaseModel):
"""更新应用"""
app_name: Optional[str] = None
base_url: Optional[str] = None
description: Optional[str] = None
tools: Optional[List[ToolItem]] = None
config_schema: Optional[List[ConfigSchemaItem]] = None
require_jssdk: Optional[bool] = None
status: Optional[int] = None
class GenerateUrlRequest(BaseModel):
"""生成链接请求"""
tenant_id: str
app_code: str
tool_code: Optional[str] = None # 不传则生成应用首页链接
# ============ API Endpoints ============
@router.get("")
async def list_apps(
page: int = Query(1, ge=1),
size: int = Query(20, ge=1, le=1000),
status: Optional[int] = None,
user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""获取应用列表"""
query = db.query(App)
if status is not None:
query = query.filter(App.status == status)
total = query.count()
apps = query.order_by(App.id.asc()).offset((page - 1) * size).limit(size).all()
return {
"total": total,
"page": page,
"size": size,
"items": [format_app(app) for app in apps]
}
@router.get("/all")
async def list_all_apps(
user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""获取所有启用的应用(用于下拉选择)"""
apps = db.query(App).filter(App.status == 1).order_by(App.id.asc()).all()
return [{"app_code": app.app_code, "app_name": app.app_name} for app in apps]
@router.get("/{app_id}")
async def get_app(
app_id: int,
user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""获取应用详情"""
app = db.query(App).filter(App.id == app_id).first()
if not app:
raise HTTPException(status_code=404, detail="应用不存在")
return format_app(app)
@router.post("")
async def create_app(
data: AppCreate,
user: User = Depends(require_operator),
db: Session = Depends(get_db)
):
"""创建应用"""
# 检查 app_code 是否重复
exists = db.query(App).filter(App.app_code == data.app_code).first()
if exists:
raise HTTPException(status_code=400, detail="应用代码已存在")
app = App(
app_code=data.app_code,
app_name=data.app_name,
base_url=data.base_url,
description=data.description,
tools=json.dumps([t.model_dump() for t in data.tools], ensure_ascii=False) if data.tools else None,
config_schema=json.dumps([c.model_dump() for c in data.config_schema], ensure_ascii=False) if data.config_schema else None,
require_jssdk=1 if data.require_jssdk else 0,
status=1
)
db.add(app)
db.commit()
db.refresh(app)
return {"success": True, "id": app.id}
@router.put("/{app_id}")
async def update_app(
app_id: int,
data: AppUpdate,
user: User = Depends(require_operator),
db: Session = Depends(get_db)
):
"""更新应用"""
app = db.query(App).filter(App.id == app_id).first()
if not app:
raise HTTPException(status_code=404, detail="应用不存在")
update_data = data.model_dump(exclude_unset=True)
# 处理 tools JSON
if 'tools' in update_data:
if update_data['tools']:
update_data['tools'] = json.dumps([t.model_dump() if hasattr(t, 'model_dump') else t for t in update_data['tools']], ensure_ascii=False)
else:
update_data['tools'] = None
# 处理 config_schema JSON
if 'config_schema' in update_data:
if update_data['config_schema']:
update_data['config_schema'] = json.dumps([c.model_dump() if hasattr(c, 'model_dump') else c for c in update_data['config_schema']], ensure_ascii=False)
else:
update_data['config_schema'] = None
# 处理 require_jssdk
if 'require_jssdk' in update_data:
update_data['require_jssdk'] = 1 if update_data['require_jssdk'] else 0
for key, value in update_data.items():
setattr(app, key, value)
db.commit()
return {"success": True}
@router.delete("/{app_id}")
async def delete_app(
app_id: int,
user: User = Depends(require_operator),
db: Session = Depends(get_db)
):
"""删除应用"""
app = db.query(App).filter(App.id == app_id).first()
if not app:
raise HTTPException(status_code=404, detail="应用不存在")
# 检查是否有租户在使用
tenant_count = db.query(TenantApp).filter(TenantApp.app_code == app.app_code).count()
if tenant_count > 0:
raise HTTPException(status_code=400, detail=f"{tenant_count} 个租户正在使用此应用,无法删除")
db.delete(app)
db.commit()
return {"success": True}
@router.post("/generate-url")
async def generate_url(
data: GenerateUrlRequest,
user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""
生成访问链接
返回完整的可直接使用的 URL使用静态 token长期有效
"""
# 获取应用信息
app = db.query(App).filter(App.app_code == data.app_code, App.status == 1).first()
if not app:
raise HTTPException(status_code=404, detail="应用不存在或已禁用")
if not app.base_url:
raise HTTPException(status_code=400, detail="应用未配置基础URL")
# 获取租户配置
tenant_app = db.query(TenantApp).filter(
TenantApp.tenant_id == data.tenant_id,
TenantApp.app_code == data.app_code,
TenantApp.status == 1
).first()
if not tenant_app:
raise HTTPException(status_code=404, detail="租户未配置此应用")
if not tenant_app.access_token:
raise HTTPException(status_code=400, detail="租户应用未配置访问令牌")
# 构建基础 URL
base_url = app.base_url.rstrip('/')
if data.tool_code:
# 查找工具路径
tools = json.loads(app.tools) if app.tools else []
tool = next((t for t in tools if t.get('code') == data.tool_code), None)
if tool:
base_url = f"{base_url}{tool.get('path', '')}"
else:
base_url = f"{base_url}/{data.tool_code}"
# 构建参数(静态 token长期有效
params = {
"tid": data.tenant_id,
"token": tenant_app.access_token
}
# 组装 URL
query_string = "&".join([f"{k}={v}" for k, v in params.items()])
full_url = f"{base_url}?{query_string}"
return {
"success": True,
"url": full_url,
"params": params,
"note": "静态链接,长期有效"
}
@router.get("/{app_code}/tools")
async def get_app_tools(
app_code: str,
user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""获取应用的工具列表(用于配置权限时选择)"""
app = db.query(App).filter(App.app_code == app_code).first()
if not app:
raise HTTPException(status_code=404, detail="应用不存在")
tools = json.loads(app.tools) if app.tools else []
return tools
@router.get("/{app_code}/config-schema")
async def get_app_config_schema(
app_code: str,
user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""获取应用的配置项定义(用于租户订阅时渲染表单)"""
app = db.query(App).filter(App.app_code == app_code).first()
if not app:
raise HTTPException(status_code=404, detail="应用不存在")
config_schema = json.loads(app.config_schema) if app.config_schema else []
return config_schema
def format_app(app: App) -> dict:
"""格式化应用数据"""
return {
"id": app.id,
"app_code": app.app_code,
"app_name": app.app_name,
"base_url": app.base_url,
"description": app.description,
"tools": json.loads(app.tools) if app.tools else [],
"config_schema": json.loads(app.config_schema) if app.config_schema else [],
"require_jssdk": bool(app.require_jssdk),
"status": app.status,
"created_at": app.created_at,
"updated_at": app.updated_at
}

347
backend/app/routers/auth.py Normal file
View File

@@ -0,0 +1,347 @@
"""认证路由"""
import hmac
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from pydantic import BaseModel
from typing import Optional, List
from sqlalchemy.orm import Session
from ..database import get_db
from ..services.auth import (
authenticate_user,
create_access_token,
decode_token,
update_last_login,
hash_password,
TokenData,
UserInfo
)
from ..services.crypto import decrypt_config
from ..models.user import User
from ..models.tenant_app import TenantApp
from ..models.tenant_wechat_app import TenantWechatApp
router = APIRouter(prefix="/auth", tags=["认证"])
security = HTTPBearer(auto_error=False)
class LoginRequest(BaseModel):
"""登录请求"""
username: str
password: str
class LoginResponse(BaseModel):
"""登录响应"""
success: bool
token: Optional[str] = None
user: Optional[UserInfo] = None
error: Optional[str] = None
class ChangePasswordRequest(BaseModel):
"""修改密码请求"""
old_password: str
new_password: str
# 权限依赖
async def get_current_user(
credentials: Optional[HTTPAuthorizationCredentials] = Depends(security),
db: Session = Depends(get_db)
) -> User:
"""获取当前用户"""
if not credentials:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="需要登录认证",
headers={"WWW-Authenticate": "Bearer"}
)
token = credentials.credentials
token_data = decode_token(token)
if not token_data:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Token 无效或已过期",
headers={"WWW-Authenticate": "Bearer"}
)
user = db.query(User).filter(User.id == token_data.user_id).first()
if not user or user.status != 1:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="用户不存在或已禁用",
headers={"WWW-Authenticate": "Bearer"}
)
return user
async def require_admin(user: User = Depends(get_current_user)) -> User:
"""要求管理员权限"""
if user.role != 'admin':
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="需要管理员权限"
)
return user
async def require_operator(user: User = Depends(get_current_user)) -> User:
"""要求操作员以上权限"""
if user.role not in ('admin', 'operator'):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="需要操作员以上权限"
)
return user
# API 端点
@router.post("/login", response_model=LoginResponse)
async def login(request: LoginRequest, db: Session = Depends(get_db)):
"""用户登录"""
user = authenticate_user(db, request.username, request.password)
if not user:
return LoginResponse(success=False, error="用户名或密码错误")
# 更新登录时间
update_last_login(db, user.id)
# 生成 Token
token = create_access_token({
"user_id": user.id,
"username": user.username,
"role": user.role
})
return LoginResponse(
success=True,
token=token,
user=UserInfo(
id=user.id,
username=user.username,
nickname=user.nickname,
role=user.role
)
)
@router.get("/me", response_model=UserInfo)
async def get_me(user: User = Depends(get_current_user)):
"""获取当前用户信息"""
return UserInfo(
id=user.id,
username=user.username,
nickname=user.nickname,
role=user.role
)
@router.post("/change-password")
async def change_password(
request: ChangePasswordRequest,
user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""修改密码"""
from ..services.auth import verify_password
if not verify_password(request.old_password, user.password_hash):
raise HTTPException(status_code=400, detail="原密码错误")
new_hash = hash_password(request.new_password)
db.query(User).filter(User.id == user.id).update({"password_hash": new_hash})
db.commit()
return {"success": True, "message": "密码修改成功"}
@router.get("/users")
async def list_users(
user: User = Depends(require_admin),
db: Session = Depends(get_db)
):
"""获取用户列表(仅管理员)"""
users = db.query(User).all()
return [
{
"id": u.id,
"username": u.username,
"nickname": u.nickname,
"role": u.role,
"status": u.status,
"last_login_at": u.last_login_at,
"created_at": u.created_at
}
for u in users
]
class CreateUserRequest(BaseModel):
username: str
password: str
nickname: Optional[str] = None
role: str = "viewer"
@router.post("/users")
async def create_user(
request: CreateUserRequest,
user: User = Depends(require_admin),
db: Session = Depends(get_db)
):
"""创建用户(仅管理员)"""
# 检查用户名是否存在
exists = db.query(User).filter(User.username == request.username).first()
if exists:
raise HTTPException(status_code=400, detail="用户名已存在")
new_user = User(
username=request.username,
password_hash=hash_password(request.password),
nickname=request.nickname,
role=request.role,
status=1
)
db.add(new_user)
db.commit()
db.refresh(new_user)
return {"success": True, "id": new_user.id}
@router.delete("/users/{user_id}")
async def delete_user(
user_id: int,
user: User = Depends(require_admin),
db: Session = Depends(get_db)
):
"""删除用户(仅管理员)"""
if user_id == user.id:
raise HTTPException(status_code=400, detail="不能删除自己")
target = db.query(User).filter(User.id == user_id).first()
if not target:
raise HTTPException(status_code=404, detail="用户不存在")
db.delete(target)
db.commit()
return {"success": True}
# ============ Token 验证 API供外部应用调用 ============
class VerifyTokenRequest(BaseModel):
"""Token 验证请求"""
token: str
app_code: Optional[str] = None # 可选,用于验证 token 是否属于特定应用
class WechatConfig(BaseModel):
"""企微配置"""
corp_id: Optional[str] = None
agent_id: Optional[str] = None
secret: Optional[str] = None
class VerifyTokenResponse(BaseModel):
"""Token 验证响应"""
valid: bool
tenant_id: Optional[str] = None
app_code: Optional[str] = None
wechat_config: Optional[WechatConfig] = None
error: Optional[str] = None
@router.post("/verify", response_model=VerifyTokenResponse)
async def verify_token(
request: VerifyTokenRequest,
db: Session = Depends(get_db)
):
"""
验证 Token 有效性(供外部应用调用,无需登录)
外部应用收到用户请求后,可调用此接口验证 token
1. 验证 token 是否存在且有效
2. 如传入 app_code验证 token 是否属于该应用
3. 返回租户信息和企微配置
Args:
token: 访问令牌
app_code: 应用代码(可选,用于验证 token 是否属于特定应用)
Returns:
valid: 是否有效
tenant_id: 租户ID
app_code: 应用代码
wechat_config: 企微配置(如有)
"""
if not request.token:
return VerifyTokenResponse(valid=False, error="Token 不能为空")
# 根据 token 查询租户应用配置
query = db.query(TenantApp).filter(
TenantApp.access_token == request.token,
TenantApp.status == 1
)
# 如果指定了 app_code验证 token 是否属于该应用
if request.app_code:
query = query.filter(TenantApp.app_code == request.app_code)
tenant_app = query.first()
if not tenant_app:
return VerifyTokenResponse(valid=False, error="Token 无效或已过期")
# 获取关联的企微配置
wechat_config = None
if tenant_app.wechat_app_id:
wechat_app = db.query(TenantWechatApp).filter(
TenantWechatApp.id == tenant_app.wechat_app_id,
TenantWechatApp.status == 1
).first()
if wechat_app:
# 解密 secret
secret = None
if wechat_app.secret_encrypted:
try:
secret = decrypt_config(wechat_app.secret_encrypted)
except:
pass
wechat_config = WechatConfig(
corp_id=wechat_app.corp_id,
agent_id=wechat_app.agent_id,
secret=secret
)
return VerifyTokenResponse(
valid=True,
tenant_id=tenant_app.tenant_id,
app_code=tenant_app.app_code,
wechat_config=wechat_config
)
@router.get("/verify")
async def verify_token_get(
token: str,
app_code: Optional[str] = None,
db: Session = Depends(get_db)
):
"""
验证 TokenGET 方式,便于简单测试)
"""
return await verify_token(
VerifyTokenRequest(token=token, app_code=app_code),
db
)

333
backend/app/routers/cost.py Normal file
View File

@@ -0,0 +1,333 @@
"""费用管理路由"""
from typing import Optional, List
from decimal import Decimal
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.pricing import ModelPricing, TenantBilling
from ..services.cost import CostCalculator
from .auth import get_current_user, require_operator
from ..models.user import User
router = APIRouter(prefix="/cost", tags=["费用管理"])
# ============= Schemas =============
class ModelPricingCreate(BaseModel):
model_name: str
provider: Optional[str] = None
display_name: Optional[str] = None
input_price_per_1k: float = 0
output_price_per_1k: float = 0
fixed_price_per_call: float = 0
pricing_type: str = "token"
description: Optional[str] = None
class ModelPricingUpdate(BaseModel):
provider: Optional[str] = None
display_name: Optional[str] = None
input_price_per_1k: Optional[float] = None
output_price_per_1k: Optional[float] = None
fixed_price_per_call: Optional[float] = None
pricing_type: Optional[str] = None
description: Optional[str] = None
status: Optional[int] = None
class CostCalculateRequest(BaseModel):
model_name: str
input_tokens: int = 0
output_tokens: int = 0
# ============= Model Pricing API =============
@router.get("/pricing")
async def list_model_pricing(
page: int = Query(1, ge=1),
size: int = Query(50, ge=1, le=100),
provider: Optional[str] = None,
status: Optional[int] = None,
user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""获取模型价格配置列表"""
query = db.query(ModelPricing)
if provider:
query = query.filter(ModelPricing.provider == provider)
if status is not None:
query = query.filter(ModelPricing.status == status)
total = query.count()
items = query.order_by(ModelPricing.model_name).offset((page - 1) * size).limit(size).all()
return {
"total": total,
"page": page,
"size": size,
"items": [format_pricing(p) for p in items]
}
@router.get("/pricing/{pricing_id}")
async def get_model_pricing(
pricing_id: int,
user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""获取模型价格详情"""
pricing = db.query(ModelPricing).filter(ModelPricing.id == pricing_id).first()
if not pricing:
raise HTTPException(status_code=404, detail="模型价格配置不存在")
return format_pricing(pricing)
@router.post("/pricing")
async def create_model_pricing(
data: ModelPricingCreate,
user: User = Depends(require_operator),
db: Session = Depends(get_db)
):
"""创建模型价格配置"""
# 检查是否已存在
existing = db.query(ModelPricing).filter(ModelPricing.model_name == data.model_name).first()
if existing:
raise HTTPException(status_code=400, detail="该模型价格配置已存在")
pricing = ModelPricing(
model_name=data.model_name,
provider=data.provider,
display_name=data.display_name,
input_price_per_1k=Decimal(str(data.input_price_per_1k)),
output_price_per_1k=Decimal(str(data.output_price_per_1k)),
fixed_price_per_call=Decimal(str(data.fixed_price_per_call)),
pricing_type=data.pricing_type,
description=data.description,
status=1
)
db.add(pricing)
db.commit()
db.refresh(pricing)
return {"success": True, "id": pricing.id}
@router.put("/pricing/{pricing_id}")
async def update_model_pricing(
pricing_id: int,
data: ModelPricingUpdate,
user: User = Depends(require_operator),
db: Session = Depends(get_db)
):
"""更新模型价格配置"""
pricing = db.query(ModelPricing).filter(ModelPricing.id == pricing_id).first()
if not pricing:
raise HTTPException(status_code=404, detail="模型价格配置不存在")
update_data = data.model_dump(exclude_unset=True)
# 转换价格字段
for field in ['input_price_per_1k', 'output_price_per_1k', 'fixed_price_per_call']:
if field in update_data and update_data[field] is not None:
update_data[field] = Decimal(str(update_data[field]))
for key, value in update_data.items():
setattr(pricing, key, value)
db.commit()
return {"success": True}
@router.delete("/pricing/{pricing_id}")
async def delete_model_pricing(
pricing_id: int,
user: User = Depends(require_operator),
db: Session = Depends(get_db)
):
"""删除模型价格配置"""
pricing = db.query(ModelPricing).filter(ModelPricing.id == pricing_id).first()
if not pricing:
raise HTTPException(status_code=404, detail="模型价格配置不存在")
db.delete(pricing)
db.commit()
return {"success": True}
# ============= Cost Calculation API =============
@router.post("/calculate")
async def calculate_cost(
request: CostCalculateRequest,
user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""计算调用费用"""
calculator = CostCalculator(db)
cost = calculator.calculate_cost(
model_name=request.model_name,
input_tokens=request.input_tokens,
output_tokens=request.output_tokens
)
return {
"model": request.model_name,
"input_tokens": request.input_tokens,
"output_tokens": request.output_tokens,
"cost": float(cost)
}
@router.get("/summary")
async def get_cost_summary(
tenant_id: Optional[str] = None,
start_date: Optional[str] = None,
end_date: Optional[str] = None,
user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""获取费用汇总"""
calculator = CostCalculator(db)
return calculator.get_cost_summary(
tenant_id=tenant_id,
start_date=start_date,
end_date=end_date
)
@router.get("/by-tenant")
async def get_cost_by_tenant(
start_date: Optional[str] = None,
end_date: Optional[str] = None,
user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""按租户统计费用"""
calculator = CostCalculator(db)
return calculator.get_cost_by_tenant(
start_date=start_date,
end_date=end_date
)
@router.get("/by-model")
async def get_cost_by_model(
tenant_id: Optional[str] = None,
start_date: Optional[str] = None,
end_date: Optional[str] = None,
user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""按模型统计费用"""
calculator = CostCalculator(db)
return calculator.get_cost_by_model(
tenant_id=tenant_id,
start_date=start_date,
end_date=end_date
)
# ============= Billing API =============
@router.get("/billing")
async def list_billing(
page: int = Query(1, ge=1),
size: int = Query(20, ge=1, le=100),
tenant_id: Optional[str] = None,
billing_month: Optional[str] = None,
user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""获取账单列表"""
query = db.query(TenantBilling)
if tenant_id:
query = query.filter(TenantBilling.tenant_id == tenant_id)
if billing_month:
query = query.filter(TenantBilling.billing_month == billing_month)
total = query.count()
items = query.order_by(desc(TenantBilling.billing_month)).offset((page - 1) * size).limit(size).all()
return {
"total": total,
"page": page,
"size": size,
"items": [format_billing(b) for b in items]
}
@router.post("/billing/generate")
async def generate_billing(
tenant_id: str = Query(...),
billing_month: str = Query(..., description="格式: YYYY-MM"),
user: User = Depends(require_operator),
db: Session = Depends(get_db)
):
"""生成月度账单"""
calculator = CostCalculator(db)
billing = calculator.generate_monthly_billing(tenant_id, billing_month)
return {
"success": True,
"billing": format_billing(billing)
}
@router.post("/recalculate")
async def recalculate_costs(
start_date: Optional[str] = None,
end_date: Optional[str] = None,
user: User = Depends(require_operator),
db: Session = Depends(get_db)
):
"""重新计算事件费用"""
calculator = CostCalculator(db)
updated = calculator.update_event_costs(start_date, end_date)
return {
"success": True,
"updated_count": updated
}
# ============= Helper Functions =============
def format_pricing(pricing: ModelPricing) -> dict:
return {
"id": pricing.id,
"model_name": pricing.model_name,
"provider": pricing.provider,
"display_name": pricing.display_name,
"input_price_per_1k": float(pricing.input_price_per_1k or 0),
"output_price_per_1k": float(pricing.output_price_per_1k or 0),
"fixed_price_per_call": float(pricing.fixed_price_per_call or 0),
"pricing_type": pricing.pricing_type,
"description": pricing.description,
"status": pricing.status,
"created_at": pricing.created_at,
"updated_at": pricing.updated_at
}
def format_billing(billing: TenantBilling) -> dict:
return {
"id": billing.id,
"tenant_id": billing.tenant_id,
"billing_month": billing.billing_month,
"total_calls": billing.total_calls,
"total_input_tokens": billing.total_input_tokens,
"total_output_tokens": billing.total_output_tokens,
"total_cost": float(billing.total_cost or 0),
"cost_by_model": billing.cost_by_model,
"cost_by_app": billing.cost_by_app,
"status": billing.status,
"created_at": billing.created_at,
"updated_at": billing.updated_at
}

View File

@@ -1,7 +1,10 @@
"""健康检查路由"""
from fastapi import APIRouter
from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session
from sqlalchemy import text
from ..config import get_settings
from ..database import get_db
router = APIRouter(tags=["health"])
settings = get_settings()
@@ -15,3 +18,28 @@ async def health_check():
"app": settings.APP_NAME,
"version": settings.APP_VERSION
}
@router.post("/migrate/add-corp-id")
async def migrate_add_corp_id(db: Session = Depends(get_db)):
"""迁移:添加租户 corp_id 字段"""
try:
# 检查列是否存在
result = db.execute(text(
"SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS "
"WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'platform_tenants' AND COLUMN_NAME = 'corp_id'"
))
exists = result.scalar()
if exists:
return {"success": True, "message": "字段 corp_id 已存在,无需迁移"}
# 添加列
db.execute(text(
"ALTER TABLE platform_tenants ADD COLUMN corp_id VARCHAR(100) AFTER name"
))
db.commit()
return {"success": True, "message": "字段 corp_id 添加成功"}
except Exception as e:
return {"success": False, "error": str(e)}

View File

@@ -1,15 +1,38 @@
"""日志路由"""
from fastapi import APIRouter, Depends, Header, HTTPException
import csv
import io
from typing import Optional
from datetime import datetime
from fastapi import APIRouter, Depends, Header, HTTPException, Query
from fastapi.responses import StreamingResponse
from sqlalchemy.orm import Session
from sqlalchemy import desc
from ..database import get_db
from ..config import get_settings
from ..models.logs import PlatformLog
from ..schemas.logs import LogCreate, LogResponse, BatchLogRequest
from ..services.auth import decode_token
router = APIRouter(prefix="/api/logs", tags=["logs"])
router = APIRouter(prefix="/logs", tags=["logs"])
settings = get_settings()
# 尝试导入openpyxl
try:
from openpyxl import Workbook
from openpyxl.styles import Font, Alignment, PatternFill
OPENPYXL_AVAILABLE = True
except ImportError:
OPENPYXL_AVAILABLE = False
def get_current_user_optional(authorization: Optional[str] = Header(None)):
"""可选的用户认证"""
if authorization and authorization.startswith("Bearer "):
token = authorization[7:]
return decode_token(token)
return None
def verify_api_key(x_api_key: str = Header(..., alias="X-API-Key")):
"""验证API Key"""
@@ -43,3 +66,215 @@ async def batch_write_logs(
db.add_all(logs)
db.commit()
return {"success": True, "count": len(logs)}
@router.get("")
async def query_logs(
page: int = Query(1, ge=1),
size: int = Query(20, ge=1, le=100),
log_type: Optional[str] = None,
level: Optional[str] = None,
app_code: Optional[str] = None,
tenant_id: Optional[str] = None,
trace_id: Optional[str] = None,
keyword: Optional[str] = None,
db: Session = Depends(get_db),
user = Depends(get_current_user_optional)
):
"""查询日志列表"""
query = db.query(PlatformLog)
if log_type:
query = query.filter(PlatformLog.log_type == log_type)
if level:
query = query.filter(PlatformLog.level == level)
if app_code:
query = query.filter(PlatformLog.app_code == app_code)
if tenant_id:
query = query.filter(PlatformLog.tenant_id == tenant_id)
if trace_id:
query = query.filter(PlatformLog.trace_id == trace_id)
if keyword:
query = query.filter(PlatformLog.message.like(f"%{keyword}%"))
total = query.count()
items = query.order_by(desc(PlatformLog.log_time)).offset((page-1)*size).limit(size).all()
return {
"total": total,
"page": page,
"size": size,
"items": [
{
"id": item.id,
"log_type": item.log_type,
"level": item.level,
"app_code": item.app_code,
"tenant_id": item.tenant_id,
"trace_id": item.trace_id,
"message": item.message,
"path": item.path,
"method": item.method,
"status_code": item.status_code,
"duration_ms": item.duration_ms,
"ip_address": item.context.get("ip") if item.context else None,
"context": item.context,
"stack_trace": item.stack_trace,
"log_time": str(item.log_time) if item.log_time else None
}
for item in items
]
}
@router.get("/export")
async def export_logs(
format: str = Query("csv", description="导出格式: csv 或 excel"),
log_type: Optional[str] = None,
level: Optional[str] = None,
app_code: Optional[str] = None,
tenant_id: Optional[str] = None,
start_date: Optional[str] = None,
end_date: Optional[str] = None,
limit: int = Query(10000, ge=1, le=100000, description="最大导出记录数"),
db: Session = Depends(get_db),
user = Depends(get_current_user_optional)
):
"""导出日志
支持CSV和Excel格式最多导出10万条记录
"""
query = db.query(PlatformLog)
if log_type:
query = query.filter(PlatformLog.log_type == log_type)
if level:
query = query.filter(PlatformLog.level == level)
if app_code:
query = query.filter(PlatformLog.app_code == app_code)
if tenant_id:
query = query.filter(PlatformLog.tenant_id == tenant_id)
if start_date:
query = query.filter(PlatformLog.log_time >= start_date)
if end_date:
query = query.filter(PlatformLog.log_time <= end_date + " 23:59:59")
items = query.order_by(desc(PlatformLog.log_time)).limit(limit).all()
if format.lower() == "excel":
return export_excel(items)
else:
return export_csv(items)
def export_csv(logs: list) -> StreamingResponse:
"""导出为CSV格式"""
output = io.StringIO()
writer = csv.writer(output)
# 写入表头
headers = [
"ID", "类型", "级别", "应用", "租户", "Trace ID",
"消息", "路径", "方法", "状态码", "耗时(ms)",
"IP地址", "时间"
]
writer.writerow(headers)
# 写入数据
for log in logs:
ip_address = log.context.get("ip") if log.context else ""
writer.writerow([
log.id,
log.log_type,
log.level,
log.app_code or "",
log.tenant_id or "",
log.trace_id or "",
log.message or "",
log.path or "",
log.method or "",
log.status_code or "",
log.duration_ms or "",
ip_address or "",
str(log.log_time) if log.log_time else ""
])
output.seek(0)
# 生成文件名
filename = f"logs_{datetime.now().strftime('%Y%m%d_%H%M%S')}.csv"
return StreamingResponse(
iter([output.getvalue()]),
media_type="text/csv",
headers={
"Content-Disposition": f'attachment; filename="{filename}"',
"Content-Type": "text/csv; charset=utf-8-sig"
}
)
def export_excel(logs: list) -> StreamingResponse:
"""导出为Excel格式"""
if not OPENPYXL_AVAILABLE:
raise HTTPException(status_code=400, detail="Excel导出功能不可用请安装openpyxl")
wb = Workbook()
ws = wb.active
ws.title = "日志导出"
# 表头样式
header_font = Font(bold=True, color="FFFFFF")
header_fill = PatternFill(start_color="4472C4", end_color="4472C4", fill_type="solid")
header_alignment = Alignment(horizontal="center", vertical="center")
# 写入表头
headers = [
"ID", "类型", "级别", "应用", "租户", "Trace ID",
"消息", "路径", "方法", "状态码", "耗时(ms)",
"IP地址", "时间"
]
for col, header in enumerate(headers, 1):
cell = ws.cell(row=1, column=col, value=header)
cell.font = header_font
cell.fill = header_fill
cell.alignment = header_alignment
# 写入数据
for row, log in enumerate(logs, 2):
ip_address = log.context.get("ip") if log.context else ""
ws.cell(row=row, column=1, value=log.id)
ws.cell(row=row, column=2, value=log.log_type)
ws.cell(row=row, column=3, value=log.level)
ws.cell(row=row, column=4, value=log.app_code or "")
ws.cell(row=row, column=5, value=log.tenant_id or "")
ws.cell(row=row, column=6, value=log.trace_id or "")
ws.cell(row=row, column=7, value=log.message or "")
ws.cell(row=row, column=8, value=log.path or "")
ws.cell(row=row, column=9, value=log.method or "")
ws.cell(row=row, column=10, value=log.status_code or "")
ws.cell(row=row, column=11, value=log.duration_ms or "")
ws.cell(row=row, column=12, value=ip_address or "")
ws.cell(row=row, column=13, value=str(log.log_time) if log.log_time else "")
# 调整列宽
column_widths = [8, 10, 10, 12, 12, 36, 50, 30, 8, 10, 10, 15, 20]
for col, width in enumerate(column_widths, 1):
ws.column_dimensions[chr(64 + col)].width = width
# 保存到内存
output = io.BytesIO()
wb.save(output)
output.seek(0)
# 生成文件名
filename = f"logs_{datetime.now().strftime('%Y%m%d_%H%M%S')}.xlsx"
return StreamingResponse(
iter([output.getvalue()]),
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
headers={
"Content-Disposition": f'attachment; filename="{filename}"'
}
)

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,264 @@
"""配额管理路由"""
from typing import Optional, Dict, Any
from datetime import date
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.tenant import Subscription
from ..services.quota import QuotaService
from .auth import get_current_user, require_operator
from ..models.user import User
router = APIRouter(prefix="/quota", tags=["配额管理"])
# ============= Schemas =============
class QuotaConfigUpdate(BaseModel):
daily_calls: int = 0
daily_tokens: int = 0
monthly_calls: int = 0
monthly_tokens: int = 0
monthly_cost: float = 0
concurrent_calls: int = 0
class SubscriptionCreate(BaseModel):
tenant_id: str
app_code: str
start_date: Optional[str] = None
end_date: Optional[str] = None
quota: QuotaConfigUpdate
class SubscriptionUpdate(BaseModel):
start_date: Optional[str] = None
end_date: Optional[str] = None
quota: Optional[QuotaConfigUpdate] = None
status: Optional[str] = None
# ============= Quota Check API =============
@router.get("/check")
async def check_quota(
tenant_id: str = Query(..., alias="tid"),
app_code: str = Query(..., alias="aid"),
estimated_tokens: int = Query(0),
db: Session = Depends(get_db)
):
"""检查配额是否足够
用于调用前检查,返回是否允许继续调用
"""
service = QuotaService(db)
result = service.check_quota(tenant_id, app_code, estimated_tokens)
return {
"allowed": result.allowed,
"reason": result.reason,
"quota_type": result.quota_type,
"limit": result.limit,
"used": result.used,
"remaining": result.remaining
}
@router.get("/summary")
async def get_quota_summary(
tenant_id: str = Query(...),
app_code: str = Query(...),
user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""获取配额使用汇总"""
service = QuotaService(db)
return service.get_quota_summary(tenant_id, app_code)
@router.get("/usage")
async def get_quota_usage(
tenant_id: str = Query(...),
app_code: str = Query(...),
user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""获取配额使用情况"""
service = QuotaService(db)
usage = service.get_usage(tenant_id, app_code)
return {
"daily_calls": usage.daily_calls,
"daily_tokens": usage.daily_tokens,
"monthly_calls": usage.monthly_calls,
"monthly_tokens": usage.monthly_tokens,
"monthly_cost": round(usage.monthly_cost, 2)
}
# ============= Subscription API =============
@router.get("/subscriptions")
async def list_subscriptions(
page: int = Query(1, ge=1),
size: int = Query(20, ge=1, le=100),
tenant_id: Optional[str] = None,
app_code: Optional[str] = None,
status: Optional[str] = None,
user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""获取订阅列表"""
query = db.query(Subscription)
if tenant_id:
query = query.filter(Subscription.tenant_id == tenant_id)
if app_code:
query = query.filter(Subscription.app_code == app_code)
if status:
query = query.filter(Subscription.status == status)
total = query.count()
items = query.order_by(desc(Subscription.created_at)).offset((page - 1) * size).limit(size).all()
return {
"total": total,
"page": page,
"size": size,
"items": [format_subscription(s) for s in items]
}
@router.get("/subscriptions/{subscription_id}")
async def get_subscription(
subscription_id: int,
user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""获取订阅详情"""
subscription = db.query(Subscription).filter(Subscription.id == subscription_id).first()
if not subscription:
raise HTTPException(status_code=404, detail="订阅不存在")
return format_subscription(subscription)
@router.post("/subscriptions")
async def create_subscription(
data: SubscriptionCreate,
user: User = Depends(require_operator),
db: Session = Depends(get_db)
):
"""创建订阅"""
# 检查是否已存在
existing = db.query(Subscription).filter(
Subscription.tenant_id == data.tenant_id,
Subscription.app_code == data.app_code,
Subscription.status == 'active'
).first()
if existing:
raise HTTPException(status_code=400, detail="该租户应用已有活跃订阅")
subscription = Subscription(
tenant_id=data.tenant_id,
app_code=data.app_code,
start_date=data.start_date or date.today(),
end_date=data.end_date,
quota=data.quota.model_dump() if data.quota else {},
status='active'
)
db.add(subscription)
db.commit()
db.refresh(subscription)
return {"success": True, "id": subscription.id}
@router.put("/subscriptions/{subscription_id}")
async def update_subscription(
subscription_id: int,
data: SubscriptionUpdate,
user: User = Depends(require_operator),
db: Session = Depends(get_db)
):
"""更新订阅"""
subscription = db.query(Subscription).filter(Subscription.id == subscription_id).first()
if not subscription:
raise HTTPException(status_code=404, detail="订阅不存在")
if data.start_date:
subscription.start_date = data.start_date
if data.end_date:
subscription.end_date = data.end_date
if data.quota:
subscription.quota = data.quota.model_dump()
if data.status:
subscription.status = data.status
db.commit()
# 清除缓存
service = QuotaService(db)
cache_key = f"quota:config:{subscription.tenant_id}:{subscription.app_code}"
service._cache.delete(cache_key)
return {"success": True}
@router.delete("/subscriptions/{subscription_id}")
async def delete_subscription(
subscription_id: int,
user: User = Depends(require_operator),
db: Session = Depends(get_db)
):
"""删除订阅"""
subscription = db.query(Subscription).filter(Subscription.id == subscription_id).first()
if not subscription:
raise HTTPException(status_code=404, detail="订阅不存在")
db.delete(subscription)
db.commit()
return {"success": True}
@router.put("/subscriptions/{subscription_id}/quota")
async def update_quota(
subscription_id: int,
data: QuotaConfigUpdate,
user: User = Depends(require_operator),
db: Session = Depends(get_db)
):
"""更新订阅配额"""
subscription = db.query(Subscription).filter(Subscription.id == subscription_id).first()
if not subscription:
raise HTTPException(status_code=404, detail="订阅不存在")
subscription.quota = data.model_dump()
db.commit()
# 清除缓存
service = QuotaService(db)
cache_key = f"quota:config:{subscription.tenant_id}:{subscription.app_code}"
service._cache.delete(cache_key)
return {"success": True}
# ============= Helper Functions =============
def format_subscription(subscription: Subscription) -> dict:
return {
"id": subscription.id,
"tenant_id": subscription.tenant_id,
"app_code": subscription.app_code,
"start_date": str(subscription.start_date) if subscription.start_date else None,
"end_date": str(subscription.end_date) if subscription.end_date else None,
"quota": subscription.quota or {},
"status": subscription.status,
"created_at": subscription.created_at,
"updated_at": subscription.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

@@ -1,16 +1,28 @@
"""统计上报路由"""
from fastapi import APIRouter, Depends, Header, HTTPException
from datetime import datetime, timedelta
from typing import Optional
from fastapi import APIRouter, Depends, Header, HTTPException, Query
from sqlalchemy.orm import Session
from sqlalchemy import func
from ..database import get_db
from ..config import get_settings
from ..models.stats import AICallEvent
from ..models.stats import AICallEvent, TenantUsageDaily
from ..schemas.stats import AICallEventCreate, AICallEventResponse, BatchReportRequest
from ..services.auth import decode_token
router = APIRouter(prefix="/api/stats", tags=["stats"])
router = APIRouter(prefix="/stats", tags=["stats"])
settings = get_settings()
def get_current_user_optional(authorization: Optional[str] = Header(None)):
"""可选的用户认证"""
if authorization and authorization.startswith("Bearer "):
token = authorization[7:]
return decode_token(token)
return None
def verify_api_key(x_api_key: str = Header(..., alias="X-API-Key")):
"""验证API Key"""
if x_api_key != settings.API_KEY:
@@ -43,3 +55,118 @@ async def batch_report_ai_calls(
db.add_all(events)
db.commit()
return {"success": True, "count": len(events)}
@router.get("/summary")
async def get_stats_summary(
db: Session = Depends(get_db),
user = Depends(get_current_user_optional)
):
"""获取统计摘要(用于仪表盘)"""
today = datetime.now().date()
# 今日调用次数和 token 消耗
today_stats = db.query(
func.count(AICallEvent.id).label('calls'),
func.coalesce(func.sum(AICallEvent.input_tokens + AICallEvent.output_tokens), 0).label('tokens')
).filter(
func.date(AICallEvent.created_at) == today
).first()
# 本周数据
week_start = today - timedelta(days=today.weekday())
week_stats = db.query(
func.count(AICallEvent.id).label('calls'),
func.coalesce(func.sum(AICallEvent.input_tokens + AICallEvent.output_tokens), 0).label('tokens')
).filter(
func.date(AICallEvent.created_at) >= week_start
).first()
return {
"today_calls": today_stats.calls if today_stats else 0,
"today_tokens": int(today_stats.tokens) if today_stats else 0,
"week_calls": week_stats.calls if week_stats else 0,
"week_tokens": int(week_stats.tokens) if week_stats else 0
}
@router.get("/trend")
async def get_stats_trend(
days: int = Query(7, ge=1, le=30),
tenant_id: Optional[str] = None,
db: Session = Depends(get_db),
user = Depends(get_current_user_optional)
):
"""获取调用趋势数据"""
end_date = datetime.now().date()
start_date = end_date - timedelta(days=days-1)
query = db.query(
func.date(AICallEvent.created_at).label('date'),
func.count(AICallEvent.id).label('calls'),
func.coalesce(func.sum(AICallEvent.input_tokens + AICallEvent.output_tokens), 0).label('tokens')
).filter(
func.date(AICallEvent.created_at) >= start_date,
func.date(AICallEvent.created_at) <= end_date
)
if tenant_id:
query = query.filter(AICallEvent.tenant_id == tenant_id)
results = query.group_by(func.date(AICallEvent.created_at)).all()
# 转换为字典便于查找
data_map = {str(r.date): {"calls": r.calls, "tokens": int(r.tokens)} for r in results}
# 填充所有日期
trend = []
current = start_date
while current <= end_date:
date_str = str(current)
trend.append({
"date": date_str,
"calls": data_map.get(date_str, {}).get("calls", 0),
"tokens": data_map.get(date_str, {}).get("tokens", 0)
})
current += timedelta(days=1)
return {"trend": trend}
@router.get("/daily")
async def get_daily_stats(
tenant_id: Optional[str] = None,
app_code: Optional[str] = None,
start_date: Optional[str] = None,
end_date: Optional[str] = None,
db: Session = Depends(get_db),
user = Depends(get_current_user_optional)
):
"""获取每日统计数据"""
query = db.query(TenantUsageDaily)
if tenant_id:
query = query.filter(TenantUsageDaily.tenant_id == tenant_id)
if app_code:
query = query.filter(TenantUsageDaily.app_code == app_code)
if start_date:
query = query.filter(TenantUsageDaily.stat_date >= start_date)
if end_date:
query = query.filter(TenantUsageDaily.stat_date <= end_date)
results = query.order_by(TenantUsageDaily.stat_date.desc()).all()
return {
"items": [
{
"id": r.id,
"tenant_id": r.tenant_id,
"app_code": r.app_code,
"stat_date": str(r.stat_date) if r.stat_date else None,
"ai_calls": r.ai_calls or 0,
"ai_tokens": r.ai_tokens or 0,
"ai_cost": float(r.ai_cost) if r.ai_cost else 0.0
}
for r in results
]
}

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

@@ -0,0 +1,254 @@
"""租户应用配置路由"""
import json
import secrets
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel
from typing import Optional, List
from sqlalchemy.orm import Session
from ..database import get_db
from ..models.tenant_app import TenantApp
from ..models.app import App
from .auth import get_current_user, require_operator
from ..models.user import User
router = APIRouter(prefix="/tenant-apps", tags=["应用配置"])
# Schemas
class CustomConfigItem(BaseModel):
"""自定义配置项"""
key: str # 配置键
value: str # 配置值
remark: Optional[str] = None # 备注说明
class TenantAppCreate(BaseModel):
tenant_id: str
app_code: str = "tools"
app_name: Optional[str] = None
wechat_app_id: Optional[int] = None # 关联的企微应用ID
access_token: Optional[str] = None # 如果不传则自动生成
allowed_origins: Optional[List[str]] = None
allowed_tools: Optional[List[str]] = None
custom_configs: Optional[List[CustomConfigItem]] = None # 自定义配置
class TenantAppUpdate(BaseModel):
app_name: Optional[str] = None
wechat_app_id: Optional[int] = None # 关联的企微应用ID
access_token: Optional[str] = None
allowed_origins: Optional[List[str]] = None
allowed_tools: Optional[List[str]] = None
custom_configs: Optional[List[CustomConfigItem]] = None # 自定义配置
status: Optional[int] = None
# API Endpoints
@router.get("")
async def list_tenant_apps(
page: int = Query(1, ge=1),
size: int = Query(20, ge=1, le=1000),
tenant_id: Optional[str] = None,
app_code: Optional[str] = None,
user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""获取应用配置列表"""
query = db.query(TenantApp)
if tenant_id:
query = query.filter(TenantApp.tenant_id == tenant_id)
if app_code:
query = query.filter(TenantApp.app_code == app_code)
total = query.count()
apps = query.order_by(TenantApp.id.desc()).offset((page - 1) * size).limit(size).all()
return {
"total": total,
"page": page,
"size": size,
"items": [format_tenant_app(app, mask_secret=True, db=db) for app in apps]
}
@router.get("/{app_id}")
async def get_tenant_app(
app_id: int,
user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""获取应用配置详情"""
app = db.query(TenantApp).filter(TenantApp.id == app_id).first()
if not app:
raise HTTPException(status_code=404, detail="应用配置不存在")
return format_tenant_app(app, mask_secret=True, db=db)
@router.post("")
async def create_tenant_app(
data: TenantAppCreate,
user: User = Depends(require_operator),
db: Session = Depends(get_db)
):
"""创建应用配置"""
# 验证 app_code 是否存在于应用管理中
app_exists = db.query(App).filter(App.app_code == data.app_code, App.status == 1).first()
if not app_exists:
raise HTTPException(status_code=400, detail=f"应用 '{data.app_code}' 不存在,请先在应用管理中创建")
# 检查是否重复
exists = db.query(TenantApp).filter(
TenantApp.tenant_id == data.tenant_id,
TenantApp.app_code == data.app_code
).first()
if exists:
raise HTTPException(status_code=400, detail="该租户应用配置已存在")
# 自动生成 access_token
access_token = data.access_token or secrets.token_hex(32)
app = TenantApp(
tenant_id=data.tenant_id,
app_code=data.app_code,
app_name=data.app_name,
wechat_app_id=data.wechat_app_id,
access_token=access_token,
allowed_origins=json.dumps(data.allowed_origins) if data.allowed_origins else None,
allowed_tools=json.dumps(data.allowed_tools) if data.allowed_tools else None,
custom_configs=json.dumps([c.model_dump() for c in data.custom_configs], ensure_ascii=False) if data.custom_configs else None,
status=1
)
db.add(app)
db.commit()
db.refresh(app)
return {"success": True, "id": app.id, "access_token": access_token}
@router.put("/{app_id}")
async def update_tenant_app(
app_id: int,
data: TenantAppUpdate,
user: User = Depends(require_operator),
db: Session = Depends(get_db)
):
"""更新应用配置"""
app = db.query(TenantApp).filter(TenantApp.id == app_id).first()
if not app:
raise HTTPException(status_code=404, detail="应用配置不存在")
update_data = data.model_dump(exclude_unset=True)
# 处理 JSON 字段
if 'allowed_origins' in update_data:
update_data['allowed_origins'] = json.dumps(update_data['allowed_origins']) if update_data['allowed_origins'] else None
if 'allowed_tools' in update_data:
update_data['allowed_tools'] = json.dumps(update_data['allowed_tools']) if update_data['allowed_tools'] else None
if 'custom_configs' in update_data:
if update_data['custom_configs']:
update_data['custom_configs'] = json.dumps(
[c.model_dump() if hasattr(c, 'model_dump') else c for c in update_data['custom_configs']],
ensure_ascii=False
)
else:
update_data['custom_configs'] = None
for key, value in update_data.items():
setattr(app, key, value)
db.commit()
return {"success": True}
@router.delete("/{app_id}")
async def delete_tenant_app(
app_id: int,
user: User = Depends(require_operator),
db: Session = Depends(get_db)
):
"""删除应用配置"""
app = db.query(TenantApp).filter(TenantApp.id == app_id).first()
if not app:
raise HTTPException(status_code=404, detail="应用配置不存在")
db.delete(app)
db.commit()
return {"success": True}
@router.get("/{app_id}/token")
async def get_token(
app_id: int,
user: User = Depends(require_operator),
db: Session = Depends(get_db)
):
"""获取真实的 access_token仅管理员可用"""
app = db.query(TenantApp).filter(TenantApp.id == app_id).first()
if not app:
raise HTTPException(status_code=404, detail="应用配置不存在")
# 获取应用的 base_url
app_info = db.query(App).filter(App.app_code == app.app_code).first()
base_url = app_info.base_url if app_info else ""
return {
"access_token": app.access_token,
"base_url": base_url
}
@router.post("/{app_id}/regenerate-token")
async def regenerate_token(
app_id: int,
user: User = Depends(require_operator),
db: Session = Depends(get_db)
):
"""重新生成 access_token"""
app = db.query(TenantApp).filter(TenantApp.id == app_id).first()
if not app:
raise HTTPException(status_code=404, detail="应用配置不存在")
new_token = secrets.token_hex(32)
app.access_token = new_token
db.commit()
return {"success": True, "access_token": new_token}
def format_tenant_app(app: TenantApp, mask_secret: bool = True, db: Session = None) -> dict:
"""格式化应用配置"""
# 获取关联的企微应用信息
wechat_app_info = None
if app.wechat_app_id and db:
from ..models.tenant_wechat_app import TenantWechatApp
wechat_app = db.query(TenantWechatApp).filter(TenantWechatApp.id == app.wechat_app_id).first()
if wechat_app:
wechat_app_info = {
"id": wechat_app.id,
"name": wechat_app.name,
"corp_id": wechat_app.corp_id,
"agent_id": wechat_app.agent_id
}
result = {
"id": app.id,
"tenant_id": app.tenant_id,
"app_code": app.app_code,
"app_name": app.app_name,
"wechat_app_id": app.wechat_app_id,
"wechat_app": wechat_app_info,
"access_token": "******" if mask_secret and app.access_token else app.access_token,
"allowed_origins": json.loads(app.allowed_origins) if app.allowed_origins else [],
"allowed_tools": json.loads(app.allowed_tools) if app.allowed_tools else [],
"custom_configs": json.loads(app.custom_configs) if app.custom_configs else [],
"status": app.status,
"created_at": app.created_at,
"updated_at": app.updated_at
}
return result

View File

@@ -0,0 +1,198 @@
"""租户企业微信应用配置路由"""
import json
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel
from typing import Optional, List
from sqlalchemy.orm import Session
from ..database import get_db
from ..models.tenant_wechat_app import TenantWechatApp
from .auth import get_current_user, require_operator
from ..models.user import User
from ..services.crypto import encrypt_config, decrypt_config
router = APIRouter(prefix="/tenant-wechat-apps", tags=["租户企微应用"])
# Schemas
class TenantWechatAppCreate(BaseModel):
tenant_id: str
name: str
corp_id: str
agent_id: str
secret: Optional[str] = None # 明文,存储时加密
class TenantWechatAppUpdate(BaseModel):
name: Optional[str] = None
corp_id: Optional[str] = None
agent_id: Optional[str] = None
secret: Optional[str] = None
status: Optional[int] = None
# API Endpoints
@router.get("")
async def list_tenant_wechat_apps(
page: int = Query(1, ge=1),
size: int = Query(20, ge=1, le=1000),
tenant_id: Optional[str] = None,
user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""获取租户企微应用列表"""
query = db.query(TenantWechatApp)
if tenant_id:
query = query.filter(TenantWechatApp.tenant_id == tenant_id)
total = query.count()
apps = query.order_by(TenantWechatApp.id.desc()).offset((page - 1) * size).limit(size).all()
return {
"total": total,
"page": page,
"size": size,
"items": [format_wechat_app(app) for app in apps]
}
@router.get("/by-tenant/{tenant_id}")
async def list_by_tenant(
tenant_id: str,
user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""获取指定租户的所有企微应用(用于下拉选择)"""
apps = db.query(TenantWechatApp).filter(
TenantWechatApp.tenant_id == tenant_id,
TenantWechatApp.status == 1
).order_by(TenantWechatApp.id.asc()).all()
return [{"id": app.id, "name": app.name, "corp_id": app.corp_id, "agent_id": app.agent_id} for app in apps]
@router.get("/{app_id}")
async def get_tenant_wechat_app(
app_id: int,
user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""获取企微应用详情"""
app = db.query(TenantWechatApp).filter(TenantWechatApp.id == app_id).first()
if not app:
raise HTTPException(status_code=404, detail="企微应用不存在")
return format_wechat_app(app)
@router.post("")
async def create_tenant_wechat_app(
data: TenantWechatAppCreate,
user: User = Depends(require_operator),
db: Session = Depends(get_db)
):
"""创建企微应用"""
# 加密 secret
secret_encrypted = None
if data.secret:
secret_encrypted = encrypt_config(data.secret)
app = TenantWechatApp(
tenant_id=data.tenant_id,
name=data.name,
corp_id=data.corp_id,
agent_id=data.agent_id,
secret_encrypted=secret_encrypted,
status=1
)
db.add(app)
db.commit()
db.refresh(app)
return {"success": True, "id": app.id}
@router.put("/{app_id}")
async def update_tenant_wechat_app(
app_id: int,
data: TenantWechatAppUpdate,
user: User = Depends(require_operator),
db: Session = Depends(get_db)
):
"""更新企微应用"""
app = db.query(TenantWechatApp).filter(TenantWechatApp.id == app_id).first()
if not app:
raise HTTPException(status_code=404, detail="企微应用不存在")
update_data = data.model_dump(exclude_unset=True)
# 处理 secret 加密
if 'secret' in update_data:
if update_data['secret']:
app.secret_encrypted = encrypt_config(update_data['secret'])
del update_data['secret']
for key, value in update_data.items():
setattr(app, key, value)
db.commit()
return {"success": True}
@router.delete("/{app_id}")
async def delete_tenant_wechat_app(
app_id: int,
user: User = Depends(require_operator),
db: Session = Depends(get_db)
):
"""删除企微应用"""
app = db.query(TenantWechatApp).filter(TenantWechatApp.id == app_id).first()
if not app:
raise HTTPException(status_code=404, detail="企微应用不存在")
# 检查是否有租户应用在使用
from ..models.tenant_app import TenantApp
usage_count = db.query(TenantApp).filter(TenantApp.wechat_app_id == app_id).count()
if usage_count > 0:
raise HTTPException(status_code=400, detail=f"{usage_count} 个应用配置正在使用此企微应用,无法删除")
db.delete(app)
db.commit()
return {"success": True}
@router.get("/{app_id}/secret")
async def get_wechat_secret(
app_id: int,
user: User = Depends(require_operator),
db: Session = Depends(get_db)
):
"""获取解密的 secret仅操作员以上"""
app = db.query(TenantWechatApp).filter(TenantWechatApp.id == app_id).first()
if not app:
raise HTTPException(status_code=404, detail="企微应用不存在")
secret = None
if app.secret_encrypted:
secret = decrypt_config(app.secret_encrypted)
return {"secret": secret}
def format_wechat_app(app: TenantWechatApp) -> dict:
"""格式化企微应用数据"""
return {
"id": app.id,
"tenant_id": app.tenant_id,
"name": app.name,
"corp_id": app.corp_id,
"agent_id": app.agent_id,
"has_secret": bool(app.secret_encrypted),
"status": app.status,
"created_at": app.created_at,
"updated_at": app.updated_at
}

View File

@@ -0,0 +1,306 @@
"""租户管理路由"""
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel
from typing import Optional, List
from datetime import date
from sqlalchemy.orm import Session
from sqlalchemy import func
from ..database import get_db
from ..models.tenant import Tenant, Subscription
from ..models.stats import TenantUsageDaily
from .auth import get_current_user, require_operator
from ..models.user import User
router = APIRouter(prefix="/tenants", tags=["租户管理"])
# Schemas
class TenantCreate(BaseModel):
code: str
name: str
corp_id: Optional[str] = None # 企业微信企业ID
contact_info: Optional[dict] = None
status: str = "active"
expired_at: Optional[date] = None
class TenantUpdate(BaseModel):
name: Optional[str] = None
corp_id: Optional[str] = None # 企业微信企业ID
contact_info: Optional[dict] = None
status: Optional[str] = None
expired_at: Optional[date] = None
class SubscriptionCreate(BaseModel):
tenant_id: int
app_code: str
start_date: Optional[date] = None
end_date: Optional[date] = None
quota: Optional[dict] = None
status: str = "active"
class SubscriptionUpdate(BaseModel):
start_date: Optional[date] = None
end_date: Optional[date] = None
quota: Optional[dict] = None
status: Optional[str] = None
# API Endpoints
@router.get("")
async def list_tenants(
page: int = Query(1, ge=1),
size: int = Query(20, ge=1, le=1000),
status: Optional[str] = None,
keyword: Optional[str] = None,
user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""获取租户列表"""
query = db.query(Tenant)
if status:
query = query.filter(Tenant.status == status)
if keyword:
query = query.filter(
(Tenant.code.contains(keyword)) | (Tenant.name.contains(keyword))
)
total = query.count()
tenants = query.order_by(Tenant.id.desc()).offset((page - 1) * size).limit(size).all()
return {
"total": total,
"page": page,
"size": size,
"items": [
{
"id": t.id,
"code": t.code,
"name": t.name,
"corp_id": getattr(t, 'corp_id', None),
"contact_info": t.contact_info,
"status": t.status,
"expired_at": t.expired_at,
"created_at": t.created_at
}
for t in tenants
]
}
@router.get("/{tenant_id}")
async def get_tenant(
tenant_id: int,
user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""获取租户详情"""
tenant = db.query(Tenant).filter(Tenant.id == tenant_id).first()
if not tenant:
raise HTTPException(status_code=404, detail="租户不存在")
# 获取订阅
subscriptions = db.query(Subscription).filter(
Subscription.tenant_id == tenant_id
).all()
# 获取用量统计最近30天
usage = db.query(
func.sum(TenantUsageDaily.ai_calls).label('total_calls'),
func.sum(TenantUsageDaily.ai_tokens).label('total_tokens'),
func.sum(TenantUsageDaily.ai_cost).label('total_cost')
).filter(
TenantUsageDaily.tenant_id == tenant_id
).first()
return {
"id": tenant.id,
"code": tenant.code,
"name": tenant.name,
"corp_id": getattr(tenant, 'corp_id', None),
"contact_info": tenant.contact_info,
"status": tenant.status,
"expired_at": tenant.expired_at,
"created_at": tenant.created_at,
"updated_at": tenant.updated_at,
"subscriptions": [
{
"id": s.id,
"app_code": s.app_code,
"start_date": s.start_date,
"end_date": s.end_date,
"quota": s.quota,
"status": s.status
}
for s in subscriptions
],
"usage_summary": {
"total_calls": int(usage.total_calls or 0),
"total_tokens": int(usage.total_tokens or 0),
"total_cost": float(usage.total_cost or 0)
}
}
@router.post("")
async def create_tenant(
data: TenantCreate,
user: User = Depends(require_operator),
db: Session = Depends(get_db)
):
"""创建租户"""
# 检查 code 是否重复
exists = db.query(Tenant).filter(Tenant.code == data.code).first()
if exists:
raise HTTPException(status_code=400, detail="租户代码已存在")
tenant = Tenant(
code=data.code,
name=data.name,
corp_id=data.corp_id,
contact_info=data.contact_info,
status=data.status,
expired_at=data.expired_at
)
db.add(tenant)
db.commit()
db.refresh(tenant)
return {"success": True, "id": tenant.id}
@router.put("/{tenant_id}")
async def update_tenant(
tenant_id: int,
data: TenantUpdate,
user: User = Depends(require_operator),
db: Session = Depends(get_db)
):
"""更新租户"""
tenant = db.query(Tenant).filter(Tenant.id == tenant_id).first()
if not tenant:
raise HTTPException(status_code=404, detail="租户不存在")
update_data = data.model_dump(exclude_unset=True)
for key, value in update_data.items():
setattr(tenant, key, value)
db.commit()
return {"success": True}
@router.delete("/{tenant_id}")
async def delete_tenant(
tenant_id: int,
user: User = Depends(require_operator),
db: Session = Depends(get_db)
):
"""删除租户"""
tenant = db.query(Tenant).filter(Tenant.id == tenant_id).first()
if not tenant:
raise HTTPException(status_code=404, detail="租户不存在")
# 删除关联的订阅
db.query(Subscription).filter(Subscription.tenant_id == tenant_id).delete()
db.delete(tenant)
db.commit()
return {"success": True}
# 订阅管理
@router.get("/{tenant_id}/subscriptions")
async def list_subscriptions(
tenant_id: int,
user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""获取租户订阅列表"""
subscriptions = db.query(Subscription).filter(
Subscription.tenant_id == tenant_id
).all()
return [
{
"id": s.id,
"tenant_id": s.tenant_id,
"app_code": s.app_code,
"start_date": s.start_date,
"end_date": s.end_date,
"quota": s.quota,
"status": s.status,
"created_at": s.created_at
}
for s in subscriptions
]
@router.post("/{tenant_id}/subscriptions")
async def create_subscription(
tenant_id: int,
data: SubscriptionCreate,
user: User = Depends(require_operator),
db: Session = Depends(get_db)
):
"""创建订阅"""
tenant = db.query(Tenant).filter(Tenant.id == tenant_id).first()
if not tenant:
raise HTTPException(status_code=404, detail="租户不存在")
subscription = Subscription(
tenant_id=tenant_id,
app_code=data.app_code,
start_date=data.start_date,
end_date=data.end_date,
quota=data.quota,
status=data.status
)
db.add(subscription)
db.commit()
db.refresh(subscription)
return {"success": True, "id": subscription.id}
@router.put("/subscriptions/{subscription_id}")
async def update_subscription(
subscription_id: int,
data: SubscriptionUpdate,
user: User = Depends(require_operator),
db: Session = Depends(get_db)
):
"""更新订阅"""
subscription = db.query(Subscription).filter(Subscription.id == subscription_id).first()
if not subscription:
raise HTTPException(status_code=404, detail="订阅不存在")
update_data = data.model_dump(exclude_unset=True)
for key, value in update_data.items():
setattr(subscription, key, value)
db.commit()
return {"success": True}
@router.delete("/subscriptions/{subscription_id}")
async def delete_subscription(
subscription_id: int,
user: User = Depends(require_operator),
db: Session = Depends(get_db)
):
"""删除订阅"""
subscription = db.query(Subscription).filter(Subscription.id == subscription_id).first()
if not subscription:
raise HTTPException(status_code=404, detail="订阅不存在")
db.delete(subscription)
db.commit()
return {"success": True}

View File

@@ -0,0 +1,398 @@
"""租户工具配置路由"""
import json
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel
from typing import Optional, List, Dict, Any
from sqlalchemy.orm import Session
from sqlalchemy import or_
from ..database import get_db
from ..models.tool_config import ToolConfig
from .auth import get_current_user
from ..models.user import User
router = APIRouter(prefix="/tool-configs", tags=["工具配置"])
# ========================================
# Schemas
# ========================================
class ToolConfigCreate(BaseModel):
"""创建配置"""
tenant_id: str
tool_code: Optional[str] = None # NULL 表示租户级共享配置
config_type: str # datasource / jssdk / webhook / params
config_key: str
config_value: Optional[str] = None
is_encrypted: int = 0
description: Optional[str] = None
class ToolConfigUpdate(BaseModel):
"""更新配置"""
config_value: Optional[str] = None
is_encrypted: Optional[int] = None
description: Optional[str] = None
status: Optional[int] = None
class ToolConfigBatchCreate(BaseModel):
"""批量创建配置"""
tenant_id: str
tool_code: Optional[str] = None
configs: List[Dict[str, Any]] # [{config_type, config_key, config_value, description}]
# ========================================
# 工具函数
# ========================================
def format_config(config: ToolConfig, mask_secret: bool = True) -> dict:
"""格式化配置输出"""
value = config.config_value
# 如果需要掩码且是加密字段
if mask_secret and config.is_encrypted and value:
value = value[:4] + "****" + value[-4:] if len(value) > 8 else "****"
return {
"id": config.id,
"tenant_id": config.tenant_id,
"tool_code": config.tool_code,
"config_type": config.config_type,
"config_key": config.config_key,
"config_value": value,
"is_encrypted": config.is_encrypted,
"description": config.description,
"status": config.status,
"created_at": config.created_at.isoformat() if config.created_at else None,
"updated_at": config.updated_at.isoformat() if config.updated_at else None
}
# ========================================
# API Endpoints
# ========================================
@router.get("")
async def list_tool_configs(
page: int = Query(1, ge=1),
size: int = Query(20, ge=1, le=100),
tenant_id: Optional[str] = None,
tool_code: Optional[str] = None,
config_type: Optional[str] = None,
keyword: Optional[str] = None,
user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""获取配置列表"""
query = db.query(ToolConfig).filter(ToolConfig.status == 1)
if tenant_id:
query = query.filter(ToolConfig.tenant_id == tenant_id)
if tool_code:
if tool_code == "__shared__":
# 特殊标记:只查租户级共享配置
query = query.filter(ToolConfig.tool_code.is_(None))
else:
query = query.filter(ToolConfig.tool_code == tool_code)
if config_type:
query = query.filter(ToolConfig.config_type == config_type)
if keyword:
query = query.filter(
or_(
ToolConfig.config_key.like(f"%{keyword}%"),
ToolConfig.description.like(f"%{keyword}%")
)
)
total = query.count()
configs = query.order_by(ToolConfig.tool_code, ToolConfig.config_type, ToolConfig.config_key)\
.offset((page - 1) * size).limit(size).all()
return {
"total": total,
"page": page,
"size": size,
"items": [format_config(c) for c in configs]
}
@router.get("/tenant/{tenant_id}")
async def get_tenant_configs(
tenant_id: str,
tool_code: Optional[str] = None,
user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""
获取租户完整配置(合并层级)
返回结构:
{
"__shared__": {"config_key": "value", ...}, # 租户级共享配置
"customer-profile": {"config_key": "value", ...}, # 工具级配置
...
}
"""
query = db.query(ToolConfig).filter(
ToolConfig.tenant_id == tenant_id,
ToolConfig.status == 1
)
if tool_code:
# 只查指定工具 + 共享配置
query = query.filter(
or_(
ToolConfig.tool_code == tool_code,
ToolConfig.tool_code.is_(None)
)
)
configs = query.all()
result = {"__shared__": {}}
for config in configs:
tool = config.tool_code or "__shared__"
if tool not in result:
result[tool] = {}
result[tool][config.config_key] = {
"value": config.config_value,
"type": config.config_type,
"encrypted": config.is_encrypted == 1,
"description": config.description
}
return result
@router.get("/merged/{tenant_id}/{tool_code}")
async def get_merged_config(
tenant_id: str,
tool_code: str,
user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""
获取合并后的工具配置(工具级 > 租户级 > 默认值)
返回扁平化的配置字典:
{"config_key": "value", ...}
"""
# 查询租户级配置
shared_configs = db.query(ToolConfig).filter(
ToolConfig.tenant_id == tenant_id,
ToolConfig.tool_code.is_(None),
ToolConfig.status == 1
).all()
# 查询工具级配置
tool_configs = db.query(ToolConfig).filter(
ToolConfig.tenant_id == tenant_id,
ToolConfig.tool_code == tool_code,
ToolConfig.status == 1
).all()
# 合并:工具级覆盖租户级
result = {}
for config in shared_configs:
result[config.config_key] = config.config_value
for config in tool_configs:
result[config.config_key] = config.config_value
return result
@router.get("/{config_id}")
async def get_tool_config(
config_id: int,
user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""获取配置详情"""
config = db.query(ToolConfig).filter(ToolConfig.id == config_id).first()
if not config:
raise HTTPException(status_code=404, detail="配置不存在")
return format_config(config, mask_secret=False)
@router.post("")
async def create_tool_config(
data: ToolConfigCreate,
user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""创建配置"""
# 检查是否已存在
existing = db.query(ToolConfig).filter(
ToolConfig.tenant_id == data.tenant_id,
ToolConfig.tool_code == data.tool_code if data.tool_code else ToolConfig.tool_code.is_(None),
ToolConfig.config_key == data.config_key
).first()
if existing:
raise HTTPException(status_code=400, detail="配置已存在")
config = ToolConfig(
tenant_id=data.tenant_id,
tool_code=data.tool_code,
config_type=data.config_type,
config_key=data.config_key,
config_value=data.config_value,
is_encrypted=data.is_encrypted,
description=data.description
)
db.add(config)
db.commit()
db.refresh(config)
return format_config(config)
@router.post("/batch")
async def batch_create_configs(
data: ToolConfigBatchCreate,
user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""批量创建配置"""
created = []
skipped = []
for item in data.configs:
config_key = item.get("config_key")
if not config_key:
continue
# 检查是否已存在
existing = db.query(ToolConfig).filter(
ToolConfig.tenant_id == data.tenant_id,
ToolConfig.tool_code == data.tool_code if data.tool_code else ToolConfig.tool_code.is_(None),
ToolConfig.config_key == config_key
).first()
if existing:
skipped.append(config_key)
continue
config = ToolConfig(
tenant_id=data.tenant_id,
tool_code=data.tool_code,
config_type=item.get("config_type", "params"),
config_key=config_key,
config_value=item.get("config_value"),
is_encrypted=item.get("is_encrypted", 0),
description=item.get("description")
)
db.add(config)
created.append(config_key)
db.commit()
return {
"created": created,
"skipped": skipped,
"message": f"成功创建 {len(created)} 条配置,跳过 {len(skipped)} 条已存在配置"
}
@router.put("/{config_id}")
async def update_tool_config(
config_id: int,
data: ToolConfigUpdate,
user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""更新配置"""
config = db.query(ToolConfig).filter(ToolConfig.id == config_id).first()
if not config:
raise HTTPException(status_code=404, detail="配置不存在")
if data.config_value is not None:
config.config_value = data.config_value
if data.is_encrypted is not None:
config.is_encrypted = data.is_encrypted
if data.description is not None:
config.description = data.description
if data.status is not None:
config.status = data.status
db.commit()
db.refresh(config)
return format_config(config)
@router.delete("/{config_id}")
async def delete_tool_config(
config_id: int,
user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""删除配置(软删除)"""
config = db.query(ToolConfig).filter(ToolConfig.id == config_id).first()
if not config:
raise HTTPException(status_code=404, detail="配置不存在")
config.status = 0
db.commit()
return {"message": "删除成功"}
# ========================================
# 配置类型和键名定义(供前端使用)
# ========================================
@router.get("/schema/types")
async def get_config_types():
"""获取支持的配置类型"""
return {
"types": [
{"code": "datasource", "name": "数据源配置", "description": "数据库连接等"},
{"code": "jssdk", "name": "JS-SDK 配置", "description": "企微侧边栏等"},
{"code": "webhook", "name": "Webhook 配置", "description": "n8n 工作流地址等"},
{"code": "params", "name": "工具参数", "description": "各工具的自定义参数"},
{"code": "external_api", "name": "外部API配置", "description": "睿美云等外部系统对接"}
]
}
@router.get("/schema/keys")
async def get_config_keys():
"""获取预定义的配置键(供前端下拉选择)"""
return {
"datasource": [
{"key": "scrm_db_host", "name": "SCRM 数据库地址", "encrypted": False},
{"key": "scrm_db_port", "name": "SCRM 数据库端口", "encrypted": False},
{"key": "scrm_db_user", "name": "SCRM 数据库用户", "encrypted": False},
{"key": "scrm_db_password", "name": "SCRM 数据库密码", "encrypted": True},
{"key": "scrm_db_name", "name": "SCRM 数据库名", "encrypted": False}
],
"jssdk": [
{"key": "corp_id", "name": "企业ID", "encrypted": False},
{"key": "agent_id", "name": "应用ID", "encrypted": False},
{"key": "secret", "name": "应用密钥", "encrypted": True}
],
"webhook": [
{"key": "n8n_base_url", "name": "n8n 基础地址", "encrypted": False},
{"key": "webhook_brainstorm", "name": "头脑风暴 Webhook", "encrypted": False},
{"key": "webhook_high_eq", "name": "高情商回复 Webhook", "encrypted": False},
{"key": "webhook_customer_profile", "name": "客户画像 Webhook", "encrypted": False},
{"key": "webhook_consultation", "name": "面诊方案 Webhook", "encrypted": False},
{"key": "webhook_medical_compliance", "name": "医疗合规 Webhook", "encrypted": False}
],
"params": [
{"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,264 @@
"""企业微信JS-SDK路由"""
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel
from sqlalchemy.orm import Session
from ..database import get_db
from ..models.tenant_app import TenantApp
from ..models.tenant_wechat_app import TenantWechatApp
from ..services.wechat import WechatService, get_wechat_service_by_id
router = APIRouter(prefix="/wechat", tags=["企业微信"])
class JssdkSignatureRequest(BaseModel):
"""JS-SDK签名请求"""
url: str # 当前页面URL不含#及其后面部分)
class JssdkSignatureResponse(BaseModel):
"""JS-SDK签名响应"""
appId: str
agentId: str
timestamp: int
nonceStr: str
signature: str
class OAuth2UrlRequest(BaseModel):
"""OAuth2授权URL请求"""
redirect_uri: str
scope: str = "snsapi_base"
state: str = ""
class UserInfoRequest(BaseModel):
"""用户信息请求"""
code: str
@router.post("/jssdk/signature")
async def get_jssdk_signature(
request: JssdkSignatureRequest,
tenant_id: str = Query(..., alias="tid"),
app_code: str = Query(..., alias="aid"),
db: Session = Depends(get_db)
):
"""获取JS-SDK签名
用于前端初始化企业微信JS-SDK
Args:
request: 包含当前页面URL
tenant_id: 租户ID
app_code: 应用代码
Returns:
JS-SDK签名信息
"""
# 查找租户应用配置
tenant_app = db.query(TenantApp).filter(
TenantApp.tenant_id == tenant_id,
TenantApp.app_code == app_code,
TenantApp.status == 1
).first()
if not tenant_app:
raise HTTPException(status_code=404, detail="租户应用配置不存在")
if not tenant_app.wechat_app_id:
raise HTTPException(status_code=400, detail="该应用未配置企业微信")
# 获取企微服务
wechat_service = await get_wechat_service_by_id(tenant_app.wechat_app_id, db)
if not wechat_service:
raise HTTPException(status_code=404, detail="企业微信应用不存在或已禁用")
# 生成签名
signature_data = await wechat_service.get_jssdk_signature(request.url)
if not signature_data:
raise HTTPException(status_code=500, detail="获取JS-SDK签名失败")
return signature_data
@router.get("/jssdk/signature")
async def get_jssdk_signature_get(
url: str = Query(..., description="当前页面URL"),
tenant_id: str = Query(..., alias="tid"),
app_code: str = Query(..., alias="aid"),
db: Session = Depends(get_db)
):
"""获取JS-SDK签名GET方式
方便前端JSONP调用
"""
# 查找租户应用配置
tenant_app = db.query(TenantApp).filter(
TenantApp.tenant_id == tenant_id,
TenantApp.app_code == app_code,
TenantApp.status == 1
).first()
if not tenant_app:
raise HTTPException(status_code=404, detail="租户应用配置不存在")
if not tenant_app.wechat_app_id:
raise HTTPException(status_code=400, detail="该应用未配置企业微信")
# 获取企微服务
wechat_service = await get_wechat_service_by_id(tenant_app.wechat_app_id, db)
if not wechat_service:
raise HTTPException(status_code=404, detail="企业微信应用不存在或已禁用")
# 生成签名
signature_data = await wechat_service.get_jssdk_signature(url)
if not signature_data:
raise HTTPException(status_code=500, detail="获取JS-SDK签名失败")
return signature_data
@router.post("/oauth2/url")
async def get_oauth2_url(
request: OAuth2UrlRequest,
tenant_id: str = Query(..., alias="tid"),
app_code: str = Query(..., alias="aid"),
db: Session = Depends(get_db)
):
"""获取OAuth2授权URL
用于企业微信内网页获取用户身份
"""
# 查找租户应用配置
tenant_app = db.query(TenantApp).filter(
TenantApp.tenant_id == tenant_id,
TenantApp.app_code == app_code,
TenantApp.status == 1
).first()
if not tenant_app:
raise HTTPException(status_code=404, detail="租户应用配置不存在")
if not tenant_app.wechat_app_id:
raise HTTPException(status_code=400, detail="该应用未配置企业微信")
# 获取企微服务
wechat_service = await get_wechat_service_by_id(tenant_app.wechat_app_id, db)
if not wechat_service:
raise HTTPException(status_code=404, detail="企业微信应用不存在或已禁用")
# 生成OAuth2 URL
oauth_url = wechat_service.get_oauth2_url(
redirect_uri=request.redirect_uri,
scope=request.scope,
state=request.state
)
return {"url": oauth_url}
@router.post("/oauth2/userinfo")
async def get_user_info(
request: UserInfoRequest,
tenant_id: str = Query(..., alias="tid"),
app_code: str = Query(..., alias="aid"),
db: Session = Depends(get_db)
):
"""通过OAuth2 code获取用户信息
在OAuth2回调后用code换取用户信息
"""
# 查找租户应用配置
tenant_app = db.query(TenantApp).filter(
TenantApp.tenant_id == tenant_id,
TenantApp.app_code == app_code,
TenantApp.status == 1
).first()
if not tenant_app:
raise HTTPException(status_code=404, detail="租户应用配置不存在")
if not tenant_app.wechat_app_id:
raise HTTPException(status_code=400, detail="该应用未配置企业微信")
# 获取企微服务
wechat_service = await get_wechat_service_by_id(tenant_app.wechat_app_id, db)
if not wechat_service:
raise HTTPException(status_code=404, detail="企业微信应用不存在或已禁用")
# 获取用户信息
user_info = await wechat_service.get_user_info_by_code(request.code)
if not user_info:
raise HTTPException(status_code=400, detail="获取用户信息失败code可能已过期")
return user_info
@router.get("/oauth2/userinfo")
async def get_user_info_get(
code: str = Query(..., description="OAuth2回调的code"),
tenant_id: str = Query(..., alias="tid"),
app_code: str = Query(..., alias="aid"),
db: Session = Depends(get_db)
):
"""通过OAuth2 code获取用户信息GET方式"""
# 查找租户应用配置
tenant_app = db.query(TenantApp).filter(
TenantApp.tenant_id == tenant_id,
TenantApp.app_code == app_code,
TenantApp.status == 1
).first()
if not tenant_app:
raise HTTPException(status_code=404, detail="租户应用配置不存在")
if not tenant_app.wechat_app_id:
raise HTTPException(status_code=400, detail="该应用未配置企业微信")
# 获取企微服务
wechat_service = await get_wechat_service_by_id(tenant_app.wechat_app_id, db)
if not wechat_service:
raise HTTPException(status_code=404, detail="企业微信应用不存在或已禁用")
# 获取用户信息
user_info = await wechat_service.get_user_info_by_code(code)
if not user_info:
raise HTTPException(status_code=400, detail="获取用户信息失败code可能已过期")
return user_info
@router.get("/user/{user_id}")
async def get_user_detail(
user_id: str,
tenant_id: str = Query(..., alias="tid"),
app_code: str = Query(..., alias="aid"),
db: Session = Depends(get_db)
):
"""获取企业微信成员详细信息"""
# 查找租户应用配置
tenant_app = db.query(TenantApp).filter(
TenantApp.tenant_id == tenant_id,
TenantApp.app_code == app_code,
TenantApp.status == 1
).first()
if not tenant_app:
raise HTTPException(status_code=404, detail="租户应用配置不存在")
if not tenant_app.wechat_app_id:
raise HTTPException(status_code=400, detail="该应用未配置企业微信")
# 获取企微服务
wechat_service = await get_wechat_service_by_id(tenant_app.wechat_app_id, db)
if not wechat_service:
raise HTTPException(status_code=404, detail="企业微信应用不存在或已禁用")
# 获取用户详情
user_detail = await wechat_service.get_user_detail(user_id)
if not user_detail:
raise HTTPException(status_code=404, detail="用户不存在")
return user_detail

View File

@@ -1,4 +1,22 @@
"""业务服务"""
from .crypto import encrypt_value, decrypt_value
from .cache import CacheService, get_cache, get_redis_client
from .wechat import WechatService, get_wechat_service_by_id
from .alert import AlertService
from .cost import CostCalculator, calculate_cost
from .quota import QuotaService, check_quota_middleware
__all__ = ["encrypt_value", "decrypt_value"]
__all__ = [
"encrypt_value",
"decrypt_value",
"CacheService",
"get_cache",
"get_redis_client",
"WechatService",
"get_wechat_service_by_id",
"AlertService",
"CostCalculator",
"calculate_cost",
"QuotaService",
"check_quota_middleware"
]

View File

@@ -0,0 +1,455 @@
"""告警服务"""
import logging
from datetime import datetime, timedelta
from typing import Optional, List, Dict, Any
import httpx
from sqlalchemy.orm import Session
from sqlalchemy import func
from ..models.alert import AlertRule, AlertRecord, NotificationChannel
from ..models.stats import AICallEvent
from ..models.logs import PlatformLog
from .cache import get_cache
logger = logging.getLogger(__name__)
class AlertService:
"""告警服务
提供告警规则检测、告警记录管理、通知发送等功能
"""
def __init__(self, db: Session):
self.db = db
self._cache = get_cache()
async def check_all_rules(self) -> List[AlertRecord]:
"""检查所有启用的告警规则
Returns:
触发的告警记录列表
"""
rules = self.db.query(AlertRule).filter(AlertRule.status == 1).all()
triggered_alerts = []
for rule in rules:
try:
alert = await self.check_rule(rule)
if alert:
triggered_alerts.append(alert)
except Exception as e:
logger.error(f"Failed to check rule {rule.id}: {e}")
return triggered_alerts
async def check_rule(self, rule: AlertRule) -> Optional[AlertRecord]:
"""检查单个告警规则
Args:
rule: 告警规则
Returns:
触发的告警记录或None
"""
# 检查冷却期
if self._is_in_cooldown(rule):
logger.debug(f"Rule {rule.id} is in cooldown")
return None
# 检查每日告警次数限制
if self._exceeds_daily_limit(rule):
logger.debug(f"Rule {rule.id} exceeds daily limit")
return None
# 根据规则类型检查
metric_value = None
threshold_value = None
triggered = False
condition = rule.condition or {}
if rule.rule_type == 'error_rate':
triggered, metric_value, threshold_value = self._check_error_rate(rule, condition)
elif rule.rule_type == 'call_count':
triggered, metric_value, threshold_value = self._check_call_count(rule, condition)
elif rule.rule_type == 'token_usage':
triggered, metric_value, threshold_value = self._check_token_usage(rule, condition)
elif rule.rule_type == 'cost_threshold':
triggered, metric_value, threshold_value = self._check_cost_threshold(rule, condition)
elif rule.rule_type == 'latency':
triggered, metric_value, threshold_value = self._check_latency(rule, condition)
if triggered:
alert = self._create_alert_record(rule, metric_value, threshold_value)
return alert
return None
def _is_in_cooldown(self, rule: AlertRule) -> bool:
"""检查规则是否在冷却期"""
cache_key = f"alert:cooldown:{rule.id}"
return self._cache.exists(cache_key)
def _set_cooldown(self, rule: AlertRule):
"""设置规则冷却期"""
cache_key = f"alert:cooldown:{rule.id}"
self._cache.set(cache_key, "1", ttl=rule.cooldown_minutes * 60)
def _exceeds_daily_limit(self, rule: AlertRule) -> bool:
"""检查是否超过每日告警次数限制"""
today = datetime.now().date()
count = self.db.query(func.count(AlertRecord.id)).filter(
AlertRecord.rule_id == rule.id,
func.date(AlertRecord.created_at) == today
).scalar()
return count >= rule.max_alerts_per_day
def _check_error_rate(self, rule: AlertRule, condition: dict) -> tuple:
"""检查错误率"""
window_minutes = self._parse_window(condition.get('window', '5m'))
threshold = condition.get('threshold', 10) # 错误次数阈值
operator = condition.get('operator', '>')
since = datetime.now() - timedelta(minutes=window_minutes)
query = self.db.query(func.count(AICallEvent.id)).filter(
AICallEvent.created_at >= since,
AICallEvent.status == 'error'
)
# 应用作用范围
if rule.scope_type == 'tenant' and rule.scope_value:
query = query.filter(AICallEvent.tenant_id == rule.scope_value)
elif rule.scope_type == 'app' and rule.scope_value:
query = query.filter(AICallEvent.app_code == rule.scope_value)
error_count = query.scalar() or 0
triggered = self._compare(error_count, threshold, operator)
return triggered, str(error_count), str(threshold)
def _check_call_count(self, rule: AlertRule, condition: dict) -> tuple:
"""检查调用次数"""
window_minutes = self._parse_window(condition.get('window', '1h'))
threshold = condition.get('threshold', 1000)
operator = condition.get('operator', '>')
since = datetime.now() - timedelta(minutes=window_minutes)
query = self.db.query(func.count(AICallEvent.id)).filter(
AICallEvent.created_at >= since
)
if rule.scope_type == 'tenant' and rule.scope_value:
query = query.filter(AICallEvent.tenant_id == rule.scope_value)
elif rule.scope_type == 'app' and rule.scope_value:
query = query.filter(AICallEvent.app_code == rule.scope_value)
call_count = query.scalar() or 0
triggered = self._compare(call_count, threshold, operator)
return triggered, str(call_count), str(threshold)
def _check_token_usage(self, rule: AlertRule, condition: dict) -> tuple:
"""检查Token使用量"""
window_minutes = self._parse_window(condition.get('window', '1d'))
threshold = condition.get('threshold', 100000)
operator = condition.get('operator', '>')
since = datetime.now() - timedelta(minutes=window_minutes)
query = self.db.query(
func.coalesce(func.sum(AICallEvent.input_tokens + AICallEvent.output_tokens), 0)
).filter(
AICallEvent.created_at >= since
)
if rule.scope_type == 'tenant' and rule.scope_value:
query = query.filter(AICallEvent.tenant_id == rule.scope_value)
elif rule.scope_type == 'app' and rule.scope_value:
query = query.filter(AICallEvent.app_code == rule.scope_value)
token_usage = query.scalar() or 0
triggered = self._compare(token_usage, threshold, operator)
return triggered, str(token_usage), str(threshold)
def _check_cost_threshold(self, rule: AlertRule, condition: dict) -> tuple:
"""检查费用阈值"""
window_minutes = self._parse_window(condition.get('window', '1d'))
threshold = condition.get('threshold', 100) # 费用阈值(元)
operator = condition.get('operator', '>')
since = datetime.now() - timedelta(minutes=window_minutes)
query = self.db.query(
func.coalesce(func.sum(AICallEvent.cost), 0)
).filter(
AICallEvent.created_at >= since
)
if rule.scope_type == 'tenant' and rule.scope_value:
query = query.filter(AICallEvent.tenant_id == rule.scope_value)
elif rule.scope_type == 'app' and rule.scope_value:
query = query.filter(AICallEvent.app_code == rule.scope_value)
total_cost = float(query.scalar() or 0)
triggered = self._compare(total_cost, threshold, operator)
return triggered, f"¥{total_cost:.2f}", f"¥{threshold:.2f}"
def _check_latency(self, rule: AlertRule, condition: dict) -> tuple:
"""检查延迟"""
window_minutes = self._parse_window(condition.get('window', '5m'))
threshold = condition.get('threshold', 5000) # 延迟阈值(ms)
operator = condition.get('operator', '>')
percentile = condition.get('percentile', 'avg') # avg, p95, p99, max
since = datetime.now() - timedelta(minutes=window_minutes)
query = self.db.query(AICallEvent.latency_ms).filter(
AICallEvent.created_at >= since,
AICallEvent.latency_ms.isnot(None)
)
if rule.scope_type == 'tenant' and rule.scope_value:
query = query.filter(AICallEvent.tenant_id == rule.scope_value)
elif rule.scope_type == 'app' and rule.scope_value:
query = query.filter(AICallEvent.app_code == rule.scope_value)
latencies = [r.latency_ms for r in query.all()]
if not latencies:
return False, "0", str(threshold)
if percentile == 'avg':
metric = sum(latencies) / len(latencies)
elif percentile == 'max':
metric = max(latencies)
elif percentile == 'p95':
latencies.sort()
idx = int(len(latencies) * 0.95)
metric = latencies[idx] if idx < len(latencies) else latencies[-1]
elif percentile == 'p99':
latencies.sort()
idx = int(len(latencies) * 0.99)
metric = latencies[idx] if idx < len(latencies) else latencies[-1]
else:
metric = sum(latencies) / len(latencies)
triggered = self._compare(metric, threshold, operator)
return triggered, f"{metric:.0f}ms", f"{threshold}ms"
def _parse_window(self, window: str) -> int:
"""解析时间窗口字符串为分钟数"""
if window.endswith('m'):
return int(window[:-1])
elif window.endswith('h'):
return int(window[:-1]) * 60
elif window.endswith('d'):
return int(window[:-1]) * 60 * 24
else:
return int(window)
def _compare(self, value: float, threshold: float, operator: str) -> bool:
"""比较值与阈值"""
if operator == '>':
return value > threshold
elif operator == '>=':
return value >= threshold
elif operator == '<':
return value < threshold
elif operator == '<=':
return value <= threshold
elif operator == '==':
return value == threshold
elif operator == '!=':
return value != threshold
return False
def _create_alert_record(
self,
rule: AlertRule,
metric_value: str,
threshold_value: str
) -> AlertRecord:
"""创建告警记录"""
title = f"[{rule.priority.upper()}] {rule.name}"
message = f"规则 '{rule.name}' 触发告警\n当前值: {metric_value}\n阈值: {threshold_value}"
if rule.scope_type == 'tenant':
message += f"\n租户: {rule.scope_value}"
elif rule.scope_type == 'app':
message += f"\n应用: {rule.scope_value}"
alert = AlertRecord(
rule_id=rule.id,
rule_name=rule.name,
alert_type=rule.rule_type,
severity=self._priority_to_severity(rule.priority),
title=title,
message=message,
tenant_id=rule.scope_value if rule.scope_type == 'tenant' else None,
app_code=rule.scope_value if rule.scope_type == 'app' else None,
metric_value=metric_value,
threshold_value=threshold_value,
notification_status='pending'
)
self.db.add(alert)
self.db.commit()
self.db.refresh(alert)
# 设置冷却期
self._set_cooldown(rule)
logger.info(f"Alert triggered: {title}")
return alert
def _priority_to_severity(self, priority: str) -> str:
"""将优先级转换为严重程度"""
mapping = {
'low': 'info',
'medium': 'warning',
'high': 'error',
'critical': 'critical'
}
return mapping.get(priority, 'warning')
async def send_notification(self, alert: AlertRecord, rule: AlertRule) -> bool:
"""发送告警通知
Args:
alert: 告警记录
rule: 告警规则
Returns:
是否发送成功
"""
if not rule.notification_channels:
alert.notification_status = 'skipped'
self.db.commit()
return True
results = []
success = True
for channel_config in rule.notification_channels:
try:
result = await self._send_to_channel(channel_config, alert)
results.append(result)
if not result.get('success'):
success = False
except Exception as e:
logger.error(f"Failed to send notification: {e}")
results.append({'success': False, 'error': str(e)})
success = False
alert.notification_status = 'sent' if success else 'failed'
alert.notification_result = results
alert.notified_at = datetime.now()
self.db.commit()
return success
async def _send_to_channel(self, channel_config: dict, alert: AlertRecord) -> dict:
"""发送到指定渠道"""
channel_type = channel_config.get('type')
if channel_type == 'wechat_bot':
return await self._send_wechat_bot(channel_config, alert)
elif channel_type == 'webhook':
return await self._send_webhook(channel_config, alert)
else:
return {'success': False, 'error': f'Unsupported channel type: {channel_type}'}
async def _send_wechat_bot(self, config: dict, alert: AlertRecord) -> dict:
"""发送到企微机器人"""
webhook = config.get('webhook')
if not webhook:
return {'success': False, 'error': 'Missing webhook URL'}
# 构建消息
content = f"**{alert.title}**\n\n{alert.message}\n\n时间: {alert.created_at}"
payload = {
"msgtype": "markdown",
"markdown": {
"content": content
}
}
try:
async with httpx.AsyncClient(timeout=10) as client:
response = await client.post(webhook, json=payload)
result = response.json()
if result.get('errcode', 0) == 0:
return {'success': True, 'channel': 'wechat_bot'}
else:
return {'success': False, 'error': result.get('errmsg')}
except Exception as e:
return {'success': False, 'error': str(e)}
async def _send_webhook(self, config: dict, alert: AlertRecord) -> dict:
"""发送到Webhook"""
url = config.get('url')
if not url:
return {'success': False, 'error': 'Missing webhook URL'}
payload = {
"alert_id": alert.id,
"title": alert.title,
"message": alert.message,
"severity": alert.severity,
"alert_type": alert.alert_type,
"metric_value": alert.metric_value,
"threshold_value": alert.threshold_value,
"created_at": alert.created_at.isoformat()
}
headers = config.get('headers', {})
method = config.get('method', 'POST')
try:
async with httpx.AsyncClient(timeout=10) as client:
if method.upper() == 'POST':
response = await client.post(url, json=payload, headers=headers)
else:
response = await client.get(url, params=payload, headers=headers)
if response.status_code < 400:
return {'success': True, 'channel': 'webhook', 'status': response.status_code}
else:
return {'success': False, 'error': f'HTTP {response.status_code}'}
except Exception as e:
return {'success': False, 'error': str(e)}
def acknowledge_alert(self, alert_id: int, acknowledged_by: str) -> Optional[AlertRecord]:
"""确认告警"""
alert = self.db.query(AlertRecord).filter(AlertRecord.id == alert_id).first()
if not alert:
return None
alert.status = 'acknowledged'
alert.acknowledged_by = acknowledged_by
alert.acknowledged_at = datetime.now()
self.db.commit()
return alert
def resolve_alert(self, alert_id: int) -> Optional[AlertRecord]:
"""解决告警"""
alert = self.db.query(AlertRecord).filter(AlertRecord.id == alert_id).first()
if not alert:
return None
alert.status = 'resolved'
alert.resolved_at = datetime.now()
self.db.commit()
return alert

View File

@@ -0,0 +1,89 @@
"""认证服务"""
import bcrypt
from datetime import datetime, timedelta
from typing import Optional
from jose import JWTError, jwt
from pydantic import BaseModel
from sqlalchemy.orm import Session
from ..config import get_settings
from ..models.user import User
class TokenData(BaseModel):
"""Token 数据"""
user_id: int
username: str
role: str
class UserInfo(BaseModel):
"""用户信息"""
id: int
username: str
nickname: Optional[str]
role: str
def verify_password(plain_password: str, hashed_password: str) -> bool:
"""验证密码"""
return bcrypt.checkpw(
plain_password.encode('utf-8'),
hashed_password.encode('utf-8')
)
def hash_password(password: str) -> str:
"""哈希密码"""
return bcrypt.hashpw(
password.encode('utf-8'),
bcrypt.gensalt()
).decode('utf-8')
def create_access_token(data: dict, expires_delta: timedelta = None) -> str:
"""创建 JWT Token"""
settings = get_settings()
to_encode = data.copy()
if expires_delta:
expire = datetime.utcnow() + expires_delta
else:
expire = datetime.utcnow() + timedelta(hours=settings.JWT_EXPIRE_HOURS)
to_encode.update({"exp": expire})
return jwt.encode(to_encode, settings.JWT_SECRET, algorithm=settings.JWT_ALGORITHM)
def decode_token(token: str) -> Optional[TokenData]:
"""解析 JWT Token"""
settings = get_settings()
try:
payload = jwt.decode(token, settings.JWT_SECRET, algorithms=[settings.JWT_ALGORITHM])
return TokenData(
user_id=payload.get("user_id"),
username=payload.get("username"),
role=payload.get("role")
)
except JWTError:
return None
def authenticate_user(db: Session, username: str, password: str) -> Optional[User]:
"""认证用户"""
user = db.query(User).filter(User.username == username).first()
if not user:
return None
if not verify_password(password, user.password_hash):
return None
if user.status != 1:
return None
return user
def update_last_login(db: Session, user_id: int):
"""更新最后登录时间"""
db.query(User).filter(User.id == user_id).update(
{"last_login_at": datetime.now()}
)
db.commit()

View File

@@ -0,0 +1,309 @@
"""Redis缓存服务"""
import json
import logging
from typing import Optional, Any, Union
from functools import lru_cache
try:
import redis
from redis import Redis
REDIS_AVAILABLE = True
except ImportError:
REDIS_AVAILABLE = False
Redis = None
from ..config import get_settings
logger = logging.getLogger(__name__)
# 全局Redis连接池
_redis_pool: Optional[Any] = None
_redis_client: Optional[Any] = None
def get_redis_client() -> Optional[Any]:
"""获取Redis客户端单例"""
global _redis_pool, _redis_client
if not REDIS_AVAILABLE:
logger.warning("Redis module not installed, cache disabled")
return None
if _redis_client is not None:
return _redis_client
settings = get_settings()
try:
_redis_pool = redis.ConnectionPool.from_url(
settings.REDIS_URL,
max_connections=20,
decode_responses=True
)
_redis_client = Redis(connection_pool=_redis_pool)
# 测试连接
_redis_client.ping()
logger.info(f"Redis connected: {settings.REDIS_URL}")
return _redis_client
except Exception as e:
logger.warning(f"Redis connection failed: {e}, cache disabled")
_redis_client = None
return None
class CacheService:
"""缓存服务
提供统一的缓存接口支持Redis和内存回退
使用示例:
cache = CacheService()
# 设置缓存
cache.set("user:123", {"name": "test"}, ttl=3600)
# 获取缓存
user = cache.get("user:123")
# 删除缓存
cache.delete("user:123")
"""
def __init__(self, prefix: Optional[str] = None):
"""初始化缓存服务
Args:
prefix: 键前缀默认使用配置中的REDIS_PREFIX
"""
settings = get_settings()
self.prefix = prefix or settings.REDIS_PREFIX
self._client = get_redis_client()
# 内存回退缓存当Redis不可用时使用
self._memory_cache: dict = {}
@property
def is_available(self) -> bool:
"""Redis是否可用"""
return self._client is not None
def _make_key(self, key: str) -> str:
"""生成完整的缓存键"""
return f"{self.prefix}{key}"
def get(self, key: str, default: Any = None) -> Any:
"""获取缓存值
Args:
key: 缓存键
default: 默认值
Returns:
缓存值或默认值
"""
full_key = self._make_key(key)
if self._client:
try:
value = self._client.get(full_key)
if value is None:
return default
# 尝试解析JSON
try:
return json.loads(value)
except (json.JSONDecodeError, TypeError):
return value
except Exception as e:
logger.error(f"Cache get error: {e}")
return default
else:
# 内存回退
return self._memory_cache.get(full_key, default)
def set(
self,
key: str,
value: Any,
ttl: Optional[int] = None,
nx: bool = False
) -> bool:
"""设置缓存值
Args:
key: 缓存键
value: 缓存值
ttl: 过期时间(秒)
nx: 只在键不存在时设置
Returns:
是否设置成功
"""
full_key = self._make_key(key)
# 序列化值
if isinstance(value, (dict, list)):
serialized = json.dumps(value, ensure_ascii=False)
else:
serialized = str(value) if value is not None else ""
if self._client:
try:
if nx:
result = self._client.set(full_key, serialized, ex=ttl, nx=True)
else:
result = self._client.set(full_key, serialized, ex=ttl)
return bool(result)
except Exception as e:
logger.error(f"Cache set error: {e}")
return False
else:
# 内存回退不支持TTL和NX
if nx and full_key in self._memory_cache:
return False
self._memory_cache[full_key] = value
return True
def delete(self, key: str) -> bool:
"""删除缓存
Args:
key: 缓存键
Returns:
是否删除成功
"""
full_key = self._make_key(key)
if self._client:
try:
return bool(self._client.delete(full_key))
except Exception as e:
logger.error(f"Cache delete error: {e}")
return False
else:
return self._memory_cache.pop(full_key, None) is not None
def exists(self, key: str) -> bool:
"""检查键是否存在
Args:
key: 缓存键
Returns:
是否存在
"""
full_key = self._make_key(key)
if self._client:
try:
return bool(self._client.exists(full_key))
except Exception as e:
logger.error(f"Cache exists error: {e}")
return False
else:
return full_key in self._memory_cache
def ttl(self, key: str) -> int:
"""获取键的剩余过期时间
Args:
key: 缓存键
Returns:
剩余秒数,-1表示永不过期-2表示键不存在
"""
full_key = self._make_key(key)
if self._client:
try:
return self._client.ttl(full_key)
except Exception as e:
logger.error(f"Cache ttl error: {e}")
return -2
else:
return -1 if full_key in self._memory_cache else -2
def incr(self, key: str, amount: int = 1) -> int:
"""递增计数器
Args:
key: 缓存键
amount: 递增量
Returns:
递增后的值
"""
full_key = self._make_key(key)
if self._client:
try:
return self._client.incrby(full_key, amount)
except Exception as e:
logger.error(f"Cache incr error: {e}")
return 0
else:
current = self._memory_cache.get(full_key, 0)
new_value = int(current) + amount
self._memory_cache[full_key] = new_value
return new_value
def expire(self, key: str, ttl: int) -> bool:
"""设置键的过期时间
Args:
key: 缓存键
ttl: 过期时间(秒)
Returns:
是否设置成功
"""
full_key = self._make_key(key)
if self._client:
try:
return bool(self._client.expire(full_key, ttl))
except Exception as e:
logger.error(f"Cache expire error: {e}")
return False
else:
return full_key in self._memory_cache
def clear_prefix(self, prefix: str) -> int:
"""删除指定前缀的所有键
Args:
prefix: 键前缀
Returns:
删除的键数量
"""
full_prefix = self._make_key(prefix)
if self._client:
try:
keys = self._client.keys(f"{full_prefix}*")
if keys:
return self._client.delete(*keys)
return 0
except Exception as e:
logger.error(f"Cache clear_prefix error: {e}")
return 0
else:
count = 0
keys_to_delete = [k for k in self._memory_cache if k.startswith(full_prefix)]
for k in keys_to_delete:
del self._memory_cache[k]
count += 1
return count
# 全局缓存实例
_cache_instance: Optional[CacheService] = None
def get_cache() -> CacheService:
"""获取全局缓存实例"""
global _cache_instance
if _cache_instance is None:
_cache_instance = CacheService()
return _cache_instance

View File

@@ -0,0 +1,420 @@
"""费用计算服务"""
import logging
from datetime import datetime
from decimal import Decimal
from typing import Optional, Dict, List
from functools import lru_cache
from sqlalchemy.orm import Session
from sqlalchemy import func
from ..models.pricing import ModelPricing, TenantBilling
from ..models.stats import AICallEvent
from .cache import get_cache
logger = logging.getLogger(__name__)
class CostCalculator:
"""费用计算器
使用示例:
calculator = CostCalculator(db)
# 计算单次调用费用
cost = calculator.calculate_cost("gpt-4", input_tokens=100, output_tokens=200)
# 生成月度账单
billing = calculator.generate_monthly_billing("qiqi", "2026-01")
"""
# 默认模型价格(当数据库中无配置时使用)
DEFAULT_PRICING = {
# OpenAI
"gpt-4": {"input": 0.21, "output": 0.42}, # 元/1K tokens
"gpt-4-turbo": {"input": 0.07, "output": 0.21},
"gpt-4o": {"input": 0.035, "output": 0.105},
"gpt-4o-mini": {"input": 0.00105, "output": 0.0042},
"gpt-3.5-turbo": {"input": 0.0035, "output": 0.014},
# Anthropic
"claude-3-opus": {"input": 0.105, "output": 0.525},
"claude-3-sonnet": {"input": 0.021, "output": 0.105},
"claude-3-haiku": {"input": 0.00175, "output": 0.00875},
"claude-3.5-sonnet": {"input": 0.021, "output": 0.105},
# 国内模型
"qwen-max": {"input": 0.02, "output": 0.06},
"qwen-plus": {"input": 0.004, "output": 0.012},
"qwen-turbo": {"input": 0.002, "output": 0.006},
"glm-4": {"input": 0.01, "output": 0.01},
"glm-4-flash": {"input": 0.0001, "output": 0.0001},
"deepseek-chat": {"input": 0.001, "output": 0.002},
"deepseek-coder": {"input": 0.001, "output": 0.002},
# 默认
"default": {"input": 0.01, "output": 0.03}
}
def __init__(self, db: Session):
self.db = db
self._cache = get_cache()
self._pricing_cache: Dict[str, ModelPricing] = {}
def get_model_pricing(self, model_name: str) -> Optional[ModelPricing]:
"""获取模型价格配置
Args:
model_name: 模型名称
Returns:
ModelPricing实例或None
"""
# 尝试从缓存获取
cache_key = f"pricing:{model_name}"
cached = self._cache.get(cache_key)
if cached:
return self._dict_to_pricing(cached)
# 从数据库查询
pricing = self.db.query(ModelPricing).filter(
ModelPricing.model_name == model_name,
ModelPricing.status == 1
).first()
if pricing:
# 缓存1小时
self._cache.set(cache_key, self._pricing_to_dict(pricing), ttl=3600)
return pricing
return None
def _pricing_to_dict(self, pricing: ModelPricing) -> dict:
return {
"model_name": pricing.model_name,
"input_price_per_1k": str(pricing.input_price_per_1k),
"output_price_per_1k": str(pricing.output_price_per_1k),
"fixed_price_per_call": str(pricing.fixed_price_per_call),
"pricing_type": pricing.pricing_type
}
def _dict_to_pricing(self, d: dict) -> ModelPricing:
pricing = ModelPricing()
pricing.model_name = d.get("model_name")
pricing.input_price_per_1k = Decimal(d.get("input_price_per_1k", "0"))
pricing.output_price_per_1k = Decimal(d.get("output_price_per_1k", "0"))
pricing.fixed_price_per_call = Decimal(d.get("fixed_price_per_call", "0"))
pricing.pricing_type = d.get("pricing_type", "token")
return pricing
def calculate_cost(
self,
model_name: str,
input_tokens: int = 0,
output_tokens: int = 0,
call_count: int = 1
) -> Decimal:
"""计算调用费用
Args:
model_name: 模型名称
input_tokens: 输入token数
output_tokens: 输出token数
call_count: 调用次数
Returns:
费用(元)
"""
# 尝试获取数据库配置
pricing = self.get_model_pricing(model_name)
if pricing:
if pricing.pricing_type == 'call':
return pricing.fixed_price_per_call * call_count
elif pricing.pricing_type == 'hybrid':
token_cost = (
pricing.input_price_per_1k * Decimal(input_tokens) / 1000 +
pricing.output_price_per_1k * Decimal(output_tokens) / 1000
)
call_cost = pricing.fixed_price_per_call * call_count
return token_cost + call_cost
else: # token
return (
pricing.input_price_per_1k * Decimal(input_tokens) / 1000 +
pricing.output_price_per_1k * Decimal(output_tokens) / 1000
)
# 使用默认价格
default_prices = self.DEFAULT_PRICING.get(model_name) or self.DEFAULT_PRICING.get("default")
input_price = Decimal(str(default_prices["input"]))
output_price = Decimal(str(default_prices["output"]))
return (
input_price * Decimal(input_tokens) / 1000 +
output_price * Decimal(output_tokens) / 1000
)
def calculate_event_cost(self, event: AICallEvent) -> Decimal:
"""计算单个事件的费用
Args:
event: AI调用事件
Returns:
费用(元)
"""
return self.calculate_cost(
model_name=event.model or "default",
input_tokens=event.input_tokens or 0,
output_tokens=event.output_tokens or 0
)
def update_event_costs(self, start_date: str = None, end_date: str = None) -> int:
"""批量更新事件费用
对于cost为0或NULL的事件重新计算费用
Args:
start_date: 开始日期,格式 YYYY-MM-DD
end_date: 结束日期,格式 YYYY-MM-DD
Returns:
更新的记录数
"""
query = self.db.query(AICallEvent).filter(
(AICallEvent.cost == None) | (AICallEvent.cost == 0)
)
if start_date:
query = query.filter(AICallEvent.created_at >= start_date)
if end_date:
query = query.filter(AICallEvent.created_at <= end_date + " 23:59:59")
events = query.all()
updated = 0
for event in events:
try:
cost = self.calculate_event_cost(event)
event.cost = cost
updated += 1
except Exception as e:
logger.error(f"Failed to calculate cost for event {event.id}: {e}")
self.db.commit()
logger.info(f"Updated {updated} event costs")
return updated
def generate_monthly_billing(
self,
tenant_id: str,
billing_month: str
) -> TenantBilling:
"""生成月度账单
Args:
tenant_id: 租户ID
billing_month: 账单月份,格式 YYYY-MM
Returns:
TenantBilling实例
"""
# 检查是否已存在
existing = self.db.query(TenantBilling).filter(
TenantBilling.tenant_id == tenant_id,
TenantBilling.billing_month == billing_month
).first()
if existing:
billing = existing
else:
billing = TenantBilling(
tenant_id=tenant_id,
billing_month=billing_month
)
self.db.add(billing)
# 计算统计数据
start_date = f"{billing_month}-01"
year, month = billing_month.split("-")
if int(month) == 12:
end_date = f"{int(year)+1}-01-01"
else:
end_date = f"{year}-{int(month)+1:02d}-01"
# 聚合查询
stats = self.db.query(
func.count(AICallEvent.id).label('total_calls'),
func.coalesce(func.sum(AICallEvent.input_tokens), 0).label('total_input'),
func.coalesce(func.sum(AICallEvent.output_tokens), 0).label('total_output'),
func.coalesce(func.sum(AICallEvent.cost), 0).label('total_cost')
).filter(
AICallEvent.tenant_id == tenant_id,
AICallEvent.created_at >= start_date,
AICallEvent.created_at < end_date
).first()
billing.total_calls = stats.total_calls or 0
billing.total_input_tokens = int(stats.total_input or 0)
billing.total_output_tokens = int(stats.total_output or 0)
billing.total_cost = stats.total_cost or Decimal("0")
# 按模型统计
model_stats = self.db.query(
AICallEvent.model,
func.coalesce(func.sum(AICallEvent.cost), 0).label('cost')
).filter(
AICallEvent.tenant_id == tenant_id,
AICallEvent.created_at >= start_date,
AICallEvent.created_at < end_date
).group_by(AICallEvent.model).all()
billing.cost_by_model = {
m.model or "unknown": float(m.cost) for m in model_stats
}
# 按应用统计
app_stats = self.db.query(
AICallEvent.app_code,
func.coalesce(func.sum(AICallEvent.cost), 0).label('cost')
).filter(
AICallEvent.tenant_id == tenant_id,
AICallEvent.created_at >= start_date,
AICallEvent.created_at < end_date
).group_by(AICallEvent.app_code).all()
billing.cost_by_app = {
a.app_code or "unknown": float(a.cost) for a in app_stats
}
self.db.commit()
self.db.refresh(billing)
return billing
def get_cost_summary(
self,
tenant_id: str = None,
start_date: str = None,
end_date: str = None
) -> Dict:
"""获取费用汇总
Args:
tenant_id: 租户ID可选
start_date: 开始日期
end_date: 结束日期
Returns:
费用汇总字典
"""
query = self.db.query(
func.count(AICallEvent.id).label('total_calls'),
func.coalesce(func.sum(AICallEvent.input_tokens), 0).label('total_input'),
func.coalesce(func.sum(AICallEvent.output_tokens), 0).label('total_output'),
func.coalesce(func.sum(AICallEvent.cost), 0).label('total_cost')
)
if tenant_id:
query = query.filter(AICallEvent.tenant_id == tenant_id)
if start_date:
query = query.filter(AICallEvent.created_at >= start_date)
if end_date:
query = query.filter(AICallEvent.created_at <= end_date + " 23:59:59")
stats = query.first()
return {
"total_calls": stats.total_calls or 0,
"total_input_tokens": int(stats.total_input or 0),
"total_output_tokens": int(stats.total_output or 0),
"total_cost": float(stats.total_cost or 0)
}
def get_cost_by_tenant(
self,
start_date: str = None,
end_date: str = None
) -> List[Dict]:
"""按租户统计费用
Returns:
租户费用列表
"""
query = self.db.query(
AICallEvent.tenant_id,
func.count(AICallEvent.id).label('calls'),
func.coalesce(func.sum(AICallEvent.cost), 0).label('cost')
)
if start_date:
query = query.filter(AICallEvent.created_at >= start_date)
if end_date:
query = query.filter(AICallEvent.created_at <= end_date + " 23:59:59")
results = query.group_by(AICallEvent.tenant_id).order_by(
func.sum(AICallEvent.cost).desc()
).all()
return [
{
"tenant_id": r.tenant_id,
"calls": r.calls,
"cost": float(r.cost)
}
for r in results
]
def get_cost_by_model(
self,
tenant_id: str = None,
start_date: str = None,
end_date: str = None
) -> List[Dict]:
"""按模型统计费用
Returns:
模型费用列表
"""
query = self.db.query(
AICallEvent.model,
func.count(AICallEvent.id).label('calls'),
func.coalesce(func.sum(AICallEvent.input_tokens), 0).label('input_tokens'),
func.coalesce(func.sum(AICallEvent.output_tokens), 0).label('output_tokens'),
func.coalesce(func.sum(AICallEvent.cost), 0).label('cost')
)
if tenant_id:
query = query.filter(AICallEvent.tenant_id == tenant_id)
if start_date:
query = query.filter(AICallEvent.created_at >= start_date)
if end_date:
query = query.filter(AICallEvent.created_at <= end_date + " 23:59:59")
results = query.group_by(AICallEvent.model).order_by(
func.sum(AICallEvent.cost).desc()
).all()
return [
{
"model": r.model or "unknown",
"calls": r.calls,
"input_tokens": int(r.input_tokens),
"output_tokens": int(r.output_tokens),
"cost": float(r.cost)
}
for r in results
]
# 便捷函数
def calculate_cost(
db: Session,
model_name: str,
input_tokens: int = 0,
output_tokens: int = 0
) -> Decimal:
"""快速计算费用"""
calculator = CostCalculator(db)
return calculator.calculate_cost(model_name, input_tokens, output_tokens)

View File

@@ -35,3 +35,8 @@ def decrypt_value(encrypted_value: str) -> str:
encrypted = base64.urlsafe_b64decode(encrypted_value.encode())
decrypted = f.decrypt(encrypted)
return decrypted.decode()
# 别名
encrypt_config = encrypt_value
decrypt_config = decrypt_value

View File

@@ -0,0 +1,346 @@
"""配额管理服务"""
import logging
from datetime import datetime, date, timedelta
from typing import Optional, Dict, Any, Tuple
from dataclasses import dataclass
from sqlalchemy.orm import Session
from sqlalchemy import func
from ..models.tenant import Tenant, Subscription
from ..models.stats import AICallEvent
from .cache import get_cache
logger = logging.getLogger(__name__)
@dataclass
class QuotaConfig:
"""配额配置"""
daily_calls: int = 0 # 每日调用限制0表示无限制
daily_tokens: int = 0 # 每日Token限制
monthly_calls: int = 0 # 每月调用限制
monthly_tokens: int = 0 # 每月Token限制
monthly_cost: float = 0 # 每月费用限制(元)
concurrent_calls: int = 0 # 并发调用限制
@dataclass
class QuotaUsage:
"""配额使用情况"""
daily_calls: int = 0
daily_tokens: int = 0
monthly_calls: int = 0
monthly_tokens: int = 0
monthly_cost: float = 0
@dataclass
class QuotaCheckResult:
"""配额检查结果"""
allowed: bool
reason: Optional[str] = None
quota_type: Optional[str] = None
limit: int = 0
used: int = 0
remaining: int = 0
class QuotaService:
"""配额管理服务
使用示例:
quota_service = QuotaService(db)
# 检查配额
result = quota_service.check_quota("qiqi", "tools")
if not result.allowed:
raise HTTPException(status_code=429, detail=result.reason)
# 获取使用情况
usage = quota_service.get_usage("qiqi", "tools")
"""
# 默认配额(当无订阅配置时使用)
DEFAULT_QUOTA = QuotaConfig(
daily_calls=1000,
daily_tokens=100000,
monthly_calls=30000,
monthly_tokens=3000000,
monthly_cost=100
)
def __init__(self, db: Session):
self.db = db
self._cache = get_cache()
def get_subscription(self, tenant_id: str, app_code: str) -> Optional[Subscription]:
"""获取租户订阅配置"""
return self.db.query(Subscription).filter(
Subscription.tenant_id == tenant_id,
Subscription.app_code == app_code,
Subscription.status == 'active'
).first()
def get_quota_config(self, tenant_id: str, app_code: str) -> QuotaConfig:
"""获取配额配置
Args:
tenant_id: 租户ID
app_code: 应用代码
Returns:
QuotaConfig实例
"""
# 尝试从缓存获取
cache_key = f"quota:config:{tenant_id}:{app_code}"
cached = self._cache.get(cache_key)
if cached:
return QuotaConfig(**cached)
# 从订阅表获取
subscription = self.get_subscription(tenant_id, app_code)
if subscription and subscription.quota:
quota = subscription.quota
config = QuotaConfig(
daily_calls=quota.get('daily_calls', 0),
daily_tokens=quota.get('daily_tokens', 0),
monthly_calls=quota.get('monthly_calls', 0),
monthly_tokens=quota.get('monthly_tokens', 0),
monthly_cost=quota.get('monthly_cost', 0),
concurrent_calls=quota.get('concurrent_calls', 0)
)
else:
config = self.DEFAULT_QUOTA
# 缓存5分钟
self._cache.set(cache_key, config.__dict__, ttl=300)
return config
def get_usage(self, tenant_id: str, app_code: str) -> QuotaUsage:
"""获取配额使用情况
Args:
tenant_id: 租户ID
app_code: 应用代码
Returns:
QuotaUsage实例
"""
today = date.today()
month_start = today.replace(day=1)
# 今日使用量
daily_stats = self.db.query(
func.count(AICallEvent.id).label('calls'),
func.coalesce(func.sum(AICallEvent.input_tokens + AICallEvent.output_tokens), 0).label('tokens')
).filter(
AICallEvent.tenant_id == tenant_id,
AICallEvent.app_code == app_code,
func.date(AICallEvent.created_at) == today
).first()
# 本月使用量
monthly_stats = self.db.query(
func.count(AICallEvent.id).label('calls'),
func.coalesce(func.sum(AICallEvent.input_tokens + AICallEvent.output_tokens), 0).label('tokens'),
func.coalesce(func.sum(AICallEvent.cost), 0).label('cost')
).filter(
AICallEvent.tenant_id == tenant_id,
AICallEvent.app_code == app_code,
func.date(AICallEvent.created_at) >= month_start
).first()
return QuotaUsage(
daily_calls=daily_stats.calls or 0,
daily_tokens=int(daily_stats.tokens or 0),
monthly_calls=monthly_stats.calls or 0,
monthly_tokens=int(monthly_stats.tokens or 0),
monthly_cost=float(monthly_stats.cost or 0)
)
def check_quota(
self,
tenant_id: str,
app_code: str,
estimated_tokens: int = 0
) -> QuotaCheckResult:
"""检查配额是否足够
Args:
tenant_id: 租户ID
app_code: 应用代码
estimated_tokens: 预估Token消耗
Returns:
QuotaCheckResult实例
"""
config = self.get_quota_config(tenant_id, app_code)
usage = self.get_usage(tenant_id, app_code)
# 检查日调用次数
if config.daily_calls > 0:
if usage.daily_calls >= config.daily_calls:
return QuotaCheckResult(
allowed=False,
reason=f"已达到每日调用限制 ({config.daily_calls} 次)",
quota_type="daily_calls",
limit=config.daily_calls,
used=usage.daily_calls,
remaining=0
)
# 检查日Token限制
if config.daily_tokens > 0:
if usage.daily_tokens + estimated_tokens > config.daily_tokens:
return QuotaCheckResult(
allowed=False,
reason=f"已达到每日Token限制 ({config.daily_tokens:,})",
quota_type="daily_tokens",
limit=config.daily_tokens,
used=usage.daily_tokens,
remaining=max(0, config.daily_tokens - usage.daily_tokens)
)
# 检查月调用次数
if config.monthly_calls > 0:
if usage.monthly_calls >= config.monthly_calls:
return QuotaCheckResult(
allowed=False,
reason=f"已达到每月调用限制 ({config.monthly_calls} 次)",
quota_type="monthly_calls",
limit=config.monthly_calls,
used=usage.monthly_calls,
remaining=0
)
# 检查月Token限制
if config.monthly_tokens > 0:
if usage.monthly_tokens + estimated_tokens > config.monthly_tokens:
return QuotaCheckResult(
allowed=False,
reason=f"已达到每月Token限制 ({config.monthly_tokens:,})",
quota_type="monthly_tokens",
limit=config.monthly_tokens,
used=usage.monthly_tokens,
remaining=max(0, config.monthly_tokens - usage.monthly_tokens)
)
# 检查月费用限制
if config.monthly_cost > 0:
if usage.monthly_cost >= config.monthly_cost:
return QuotaCheckResult(
allowed=False,
reason=f"已达到每月费用限制 (¥{config.monthly_cost:.2f})",
quota_type="monthly_cost",
limit=int(config.monthly_cost * 100), # 转为分
used=int(usage.monthly_cost * 100),
remaining=max(0, int((config.monthly_cost - usage.monthly_cost) * 100))
)
# 所有检查通过
return QuotaCheckResult(
allowed=True,
quota_type="daily_calls",
limit=config.daily_calls,
used=usage.daily_calls,
remaining=max(0, config.daily_calls - usage.daily_calls) if config.daily_calls > 0 else -1
)
def get_quota_summary(self, tenant_id: str, app_code: str) -> Dict[str, Any]:
"""获取配额汇总信息
Returns:
包含配额配置和使用情况的字典
"""
config = self.get_quota_config(tenant_id, app_code)
usage = self.get_usage(tenant_id, app_code)
def calc_percentage(used: int, limit: int) -> float:
if limit <= 0:
return 0
return min(100, round(used / limit * 100, 1))
return {
"config": {
"daily_calls": config.daily_calls,
"daily_tokens": config.daily_tokens,
"monthly_calls": config.monthly_calls,
"monthly_tokens": config.monthly_tokens,
"monthly_cost": config.monthly_cost
},
"usage": {
"daily_calls": usage.daily_calls,
"daily_tokens": usage.daily_tokens,
"monthly_calls": usage.monthly_calls,
"monthly_tokens": usage.monthly_tokens,
"monthly_cost": round(usage.monthly_cost, 2)
},
"percentage": {
"daily_calls": calc_percentage(usage.daily_calls, config.daily_calls),
"daily_tokens": calc_percentage(usage.daily_tokens, config.daily_tokens),
"monthly_calls": calc_percentage(usage.monthly_calls, config.monthly_calls),
"monthly_tokens": calc_percentage(usage.monthly_tokens, config.monthly_tokens),
"monthly_cost": calc_percentage(int(usage.monthly_cost * 100), int(config.monthly_cost * 100))
}
}
def update_quota(
self,
tenant_id: str,
app_code: str,
quota_config: Dict[str, Any]
) -> Subscription:
"""更新配额配置
Args:
tenant_id: 租户ID
app_code: 应用代码
quota_config: 配额配置字典
Returns:
更新后的Subscription实例
"""
subscription = self.get_subscription(tenant_id, app_code)
if not subscription:
# 创建新订阅
subscription = Subscription(
tenant_id=tenant_id,
app_code=app_code,
start_date=date.today(),
quota=quota_config,
status='active'
)
self.db.add(subscription)
else:
# 更新现有订阅
subscription.quota = quota_config
self.db.commit()
self.db.refresh(subscription)
# 清除缓存
cache_key = f"quota:config:{tenant_id}:{app_code}"
self._cache.delete(cache_key)
return subscription
def check_quota_middleware(
db: Session,
tenant_id: str,
app_code: str,
estimated_tokens: int = 0
) -> QuotaCheckResult:
"""配额检查中间件函数
可在路由中使用:
result = check_quota_middleware(db, "qiqi", "tools")
if not result.allowed:
raise HTTPException(status_code=429, detail=result.reason)
"""
service = QuotaService(db)
return service.check_quota(tenant_id, app_code, estimated_tokens)

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

@@ -0,0 +1,371 @@
"""企业微信服务"""
import hashlib
import time
import logging
from typing import Optional, Dict, Any
from dataclasses import dataclass
import httpx
from ..config import get_settings
from .cache import get_cache
from .crypto import decrypt_config
logger = logging.getLogger(__name__)
settings = get_settings()
@dataclass
class WechatConfig:
"""企业微信应用配置"""
corp_id: str
agent_id: str
secret: str
class WechatService:
"""企业微信服务
提供access_token获取、JS-SDK签名、OAuth2等功能
使用示例:
wechat = WechatService(corp_id="wwxxxx", agent_id="1000001", secret="xxx")
# 获取access_token
token = await wechat.get_access_token()
# 获取JS-SDK签名
signature = await wechat.get_jssdk_signature("https://example.com/page")
"""
# 企业微信API基础URL
BASE_URL = "https://qyapi.weixin.qq.com"
def __init__(self, corp_id: str, agent_id: str, secret: str):
"""初始化企业微信服务
Args:
corp_id: 企业ID
agent_id: 应用AgentId
secret: 应用Secret明文
"""
self.corp_id = corp_id
self.agent_id = agent_id
self.secret = secret
self._cache = get_cache()
@classmethod
def from_wechat_app(cls, wechat_app) -> "WechatService":
"""从TenantWechatApp模型创建服务实例
Args:
wechat_app: TenantWechatApp数据库模型
Returns:
WechatService实例
"""
secret = ""
if wechat_app.secret_encrypted:
try:
secret = decrypt_config(wechat_app.secret_encrypted)
except Exception as e:
logger.error(f"Failed to decrypt secret: {e}")
return cls(
corp_id=wechat_app.corp_id,
agent_id=wechat_app.agent_id,
secret=secret
)
def _cache_key(self, key_type: str) -> str:
"""生成缓存键"""
return f"wechat:{self.corp_id}:{self.agent_id}:{key_type}"
async def get_access_token(self, force_refresh: bool = False) -> Optional[str]:
"""获取access_token
企业微信access_token有效期7200秒需要缓存
Args:
force_refresh: 是否强制刷新
Returns:
access_token或None
"""
cache_key = self._cache_key("access_token")
# 尝试从缓存获取
if not force_refresh:
cached = self._cache.get(cache_key)
if cached:
logger.debug(f"Access token from cache: {cached[:20]}...")
return cached
# 从企业微信API获取
url = f"{self.BASE_URL}/cgi-bin/gettoken"
params = {
"corpid": self.corp_id,
"corpsecret": self.secret
}
try:
async with httpx.AsyncClient(timeout=10) as client:
response = await client.get(url, params=params)
result = response.json()
if result.get("errcode", 0) != 0:
logger.error(f"Get access_token failed: {result}")
return None
access_token = result.get("access_token")
expires_in = result.get("expires_in", 7200)
# 缓存提前200秒过期以确保安全
self._cache.set(
cache_key,
access_token,
ttl=min(expires_in - 200, settings.WECHAT_ACCESS_TOKEN_EXPIRE)
)
logger.info(f"Got new access_token for {self.corp_id}")
return access_token
except Exception as e:
logger.error(f"Get access_token error: {e}")
return None
async def get_jsapi_ticket(self, force_refresh: bool = False) -> Optional[str]:
"""获取jsapi_ticket
用于生成JS-SDK签名
Args:
force_refresh: 是否强制刷新
Returns:
jsapi_ticket或None
"""
cache_key = self._cache_key("jsapi_ticket")
# 尝试从缓存获取
if not force_refresh:
cached = self._cache.get(cache_key)
if cached:
logger.debug(f"JSAPI ticket from cache: {cached[:20]}...")
return cached
# 先获取access_token
access_token = await self.get_access_token()
if not access_token:
return None
# 获取jsapi_ticket
url = f"{self.BASE_URL}/cgi-bin/get_jsapi_ticket"
params = {"access_token": access_token}
try:
async with httpx.AsyncClient(timeout=10) as client:
response = await client.get(url, params=params)
result = response.json()
if result.get("errcode", 0) != 0:
logger.error(f"Get jsapi_ticket failed: {result}")
return None
ticket = result.get("ticket")
expires_in = result.get("expires_in", 7200)
# 缓存
self._cache.set(
cache_key,
ticket,
ttl=min(expires_in - 200, settings.WECHAT_JSAPI_TICKET_EXPIRE)
)
logger.info(f"Got new jsapi_ticket for {self.corp_id}")
return ticket
except Exception as e:
logger.error(f"Get jsapi_ticket error: {e}")
return None
async def get_jssdk_signature(
self,
url: str,
noncestr: Optional[str] = None,
timestamp: Optional[int] = None
) -> Optional[Dict[str, Any]]:
"""生成JS-SDK签名
Args:
url: 当前页面URL不含#及其后面部分)
noncestr: 随机字符串,可选
timestamp: 时间戳,可选
Returns:
签名信息字典包含signature, noncestr, timestamp, appId等
"""
ticket = await self.get_jsapi_ticket()
if not ticket:
return None
# 生成随机字符串和时间戳
if noncestr is None:
import secrets
noncestr = secrets.token_hex(8)
if timestamp is None:
timestamp = int(time.time())
# 构建签名字符串
sign_str = f"jsapi_ticket={ticket}&noncestr={noncestr}&timestamp={timestamp}&url={url}"
# SHA1签名
signature = hashlib.sha1(sign_str.encode()).hexdigest()
return {
"appId": self.corp_id,
"agentId": self.agent_id,
"timestamp": timestamp,
"nonceStr": noncestr,
"signature": signature,
"url": url
}
def get_oauth2_url(
self,
redirect_uri: str,
scope: str = "snsapi_base",
state: str = ""
) -> str:
"""生成OAuth2授权URL
Args:
redirect_uri: 授权后重定向的URL
scope: 应用授权作用域
- snsapi_base: 静默授权,只能获取成员基础信息
- snsapi_privateinfo: 手动授权,可获取成员详细信息
state: 重定向后会带上state参数
Returns:
OAuth2授权URL
"""
import urllib.parse
encoded_uri = urllib.parse.quote(redirect_uri, safe='')
url = (
f"https://open.weixin.qq.com/connect/oauth2/authorize"
f"?appid={self.corp_id}"
f"&redirect_uri={encoded_uri}"
f"&response_type=code"
f"&scope={scope}"
f"&state={state}"
f"&agentid={self.agent_id}"
f"#wechat_redirect"
)
return url
async def get_user_info_by_code(self, code: str) -> Optional[Dict[str, Any]]:
"""通过OAuth2 code获取用户信息
Args:
code: OAuth2回调返回的code
Returns:
用户信息字典包含UserId, DeviceId等
"""
access_token = await self.get_access_token()
if not access_token:
return None
url = f"{self.BASE_URL}/cgi-bin/auth/getuserinfo"
params = {
"access_token": access_token,
"code": code
}
try:
async with httpx.AsyncClient(timeout=10) as client:
response = await client.get(url, params=params)
result = response.json()
if result.get("errcode", 0) != 0:
logger.error(f"Get user info by code failed: {result}")
return None
return {
"user_id": result.get("userid") or result.get("UserId"),
"device_id": result.get("deviceid") or result.get("DeviceId"),
"open_id": result.get("openid") or result.get("OpenId"),
"external_userid": result.get("external_userid"),
}
except Exception as e:
logger.error(f"Get user info by code error: {e}")
return None
async def get_user_detail(self, user_id: str) -> Optional[Dict[str, Any]]:
"""获取成员详细信息
Args:
user_id: 成员UserID
Returns:
成员详细信息
"""
access_token = await self.get_access_token()
if not access_token:
return None
url = f"{self.BASE_URL}/cgi-bin/user/get"
params = {
"access_token": access_token,
"userid": user_id
}
try:
async with httpx.AsyncClient(timeout=10) as client:
response = await client.get(url, params=params)
result = response.json()
if result.get("errcode", 0) != 0:
logger.error(f"Get user detail failed: {result}")
return None
return {
"userid": result.get("userid"),
"name": result.get("name"),
"department": result.get("department"),
"position": result.get("position"),
"mobile": result.get("mobile"),
"email": result.get("email"),
"avatar": result.get("avatar"),
"status": result.get("status"),
}
except Exception as e:
logger.error(f"Get user detail error: {e}")
return None
async def get_wechat_service_by_id(
wechat_app_id: int,
db_session
) -> Optional[WechatService]:
"""根据企微应用ID获取服务实例
Args:
wechat_app_id: platform_tenant_wechat_apps表的ID
db_session: 数据库session
Returns:
WechatService实例或None
"""
from ..models.tenant_wechat_app import TenantWechatApp
wechat_app = db_session.query(TenantWechatApp).filter(
TenantWechatApp.id == wechat_app_id,
TenantWechatApp.status == 1
).first()
if not wechat_app:
return None
return WechatService.from_wechat_app(wechat_app)

26
backend/env.template Normal file
View File

@@ -0,0 +1,26 @@
# 000-platform 环境配置模板
# 复制此文件为 .env 并填写实际值
# ==================== 应用配置 ====================
APP_NAME=platform
APP_VERSION=1.0.0
DEBUG=false
# ==================== 数据库配置 ====================
DB_HOST=localhost
DB_PORT=3306
DB_USER=root
DB_PASSWORD=your-password
DB_NAME=new_qiqi
# ==================== JWT 配置 ====================
JWT_SECRET_KEY=your-secret-key-change-in-production
JWT_ALGORITHM=HS256
JWT_EXPIRE_MINUTES=1440
# ==================== 安全配置 ====================
# 用于加密敏感数据(如企微 Secret
ENCRYPTION_KEY=your-encryption-key-32-bytes
# ==================== 可选Redis 缓存 ====================
# REDIS_URL=redis://localhost:6379/0

View File

@@ -7,5 +7,9 @@ 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,10 +1,14 @@
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 -r 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

View File

@@ -0,0 +1,27 @@
FROM node:20-alpine as builder
WORKDIR /app
# 安装依赖(使用淘宝镜像)
COPY frontend/package.json ./
RUN npm config set registry https://registry.npmmirror.com && npm install
# 构建
COPY frontend/ .
RUN npm run build
# 生产镜像
FROM nginx:alpine
# 后端服务地址(通过 build-arg 传入,构建时替换)
ARG BACKEND_HOST=platform-backend-test
COPY --from=builder /app/dist /usr/share/nginx/html
COPY deploy/nginx/frontend.conf /etc/nginx/conf.d/default.conf
# 在构建时替换后端地址(只替换 BACKEND_HOST 变量)
RUN sed -i "s/\${BACKEND_HOST}/${BACKEND_HOST}/g" /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

View File

@@ -0,0 +1,40 @@
server {
listen 80;
server_name localhost;
root /usr/share/nginx/html;
index index.html;
# Docker 内部 DNS 解析器
resolver 127.0.0.11 valid=30s;
# Vue Router history mode
location / {
try_files $uri $uri/ /index.html;
}
# API 代理到后端
# 使用环境变量 BACKEND_HOST通过 Docker DNS 解析
location /api/ {
set $backend ${BACKEND_HOST}:8000;
proxy_pass http://$backend/api/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 120s;
proxy_connect_timeout 10s;
}
# 静态资源缓存
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
# 禁止访问隐藏文件
location ~ /\. {
deny all;
}
}

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

13
frontend/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>平台管理后台</title>
<link rel="icon" href="data:,">
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

28
frontend/package.json Normal file
View File

@@ -0,0 +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",
"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"
}
}

23
frontend/src/App.vue Normal file
View File

@@ -0,0 +1,23 @@
<script setup>
import { useAuthStore } from '@/stores/auth'
import { onMounted } from 'vue'
const authStore = useAuthStore()
onMounted(() => {
// 恢复登录状态
authStore.initFromStorage()
})
</script>
<template>
<router-view />
</template>
<style>
html, body, #app {
height: 100%;
margin: 0;
padding: 0;
}
</style>

123
frontend/src/api/index.js Normal file
View File

@@ -0,0 +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) {
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

@@ -0,0 +1,185 @@
// 全局样式
* {
box-sizing: border-box;
}
body {
font-family: 'Helvetica Neue', Helvetica, 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', Arial, sans-serif;
background-color: #f5f7fa;
}
// 布局
.layout {
height: 100vh;
display: flex;
}
.sidebar {
width: 220px;
background: linear-gradient(180deg, #1e3a5f 0%, #0d2137 100%);
color: #fff;
flex-shrink: 0;
.logo {
height: 60px;
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
font-weight: 600;
border-bottom: 1px solid rgba(255,255,255,0.1);
}
.el-menu {
border: none;
background: transparent;
.el-menu-item {
color: rgba(255,255,255,0.7);
&:hover {
background: rgba(255,255,255,0.1);
}
&.is-active {
background: #409eff;
color: #fff;
}
}
}
}
.main-container {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
.header {
height: 60px;
background: #fff;
border-bottom: 1px solid #e6e6e6;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 20px;
.breadcrumb {
font-size: 14px;
}
.user-info {
display: flex;
align-items: center;
gap: 12px;
.username {
font-size: 14px;
color: #606266;
}
}
}
.main-content {
flex: 1;
padding: 20px;
overflow-y: auto;
}
// 页面容器
.page-container {
background: #fff;
border-radius: 8px;
padding: 20px;
min-height: 100%;
}
// 页面头部
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
.title {
font-size: 18px;
font-weight: 600;
color: #303133;
}
}
// 搜索栏
.search-bar {
display: flex;
gap: 12px;
margin-bottom: 20px;
flex-wrap: wrap;
}
// 统计卡片
.stat-cards {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: 20px;
margin-bottom: 20px;
}
.stat-card {
background: #fff;
border-radius: 8px;
padding: 20px;
.stat-title {
font-size: 14px;
color: #909399;
margin-bottom: 12px;
}
.stat-value {
font-size: 28px;
font-weight: 600;
color: #303133;
}
.stat-trend {
font-size: 12px;
margin-top: 8px;
&.up {
color: #67c23a;
}
&.down {
color: #f56c6c;
}
}
}
// 表格
.el-table {
.cell {
padding: 8px 12px;
}
}
// 对话框
.el-dialog {
.el-dialog__body {
padding: 20px 24px;
}
}
// 状态标签
.status-active {
color: #67c23a;
}
.status-expired {
color: #f56c6c;
}
.status-trial {
color: #e6a23c;
}

View File

@@ -0,0 +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' },
{ 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>

23
frontend/src/main.js Normal file
View File

@@ -0,0 +1,23 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import ElementPlus from 'element-plus'
import zhCn from 'element-plus/dist/locale/zh-cn.mjs'
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
import 'element-plus/dist/index.css'
import App from './App.vue'
import router from './router'
import './assets/styles/main.scss'
const app = createApp(App)
// 注册所有图标
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
app.component(key, component)
}
app.use(createPinia())
app.use(router)
app.use(ElementPlus, { locale: zhCn })
app.mount('#app')

View File

@@ -0,0 +1,116 @@
import { createRouter, createWebHistory } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
const routes = [
{
path: '/login',
name: 'Login',
component: () => import('@/views/login/index.vue'),
meta: { title: '登录', public: true }
},
{
path: '/error',
name: 'Error',
component: () => import('@/views/error/index.vue'),
meta: { title: '出错了', public: true }
},
{
path: '/',
component: () => import('@/components/Layout.vue'),
redirect: '/dashboard',
children: [
{
path: 'dashboard',
name: 'Dashboard',
component: () => import('@/views/dashboard/index.vue'),
meta: { title: '仪表盘', icon: 'Odometer' }
},
{
path: 'tenants',
name: 'Tenants',
component: () => import('@/views/tenants/index.vue'),
meta: { title: '租户管理', icon: 'OfficeBuilding' }
},
{
path: 'tenants/:id',
name: 'TenantDetail',
component: () => import('@/views/tenants/detail.vue'),
meta: { title: '租户详情', hidden: true }
},
{
path: 'apps',
name: 'Apps',
component: () => import('@/views/apps/index.vue'),
meta: { title: '应用管理', icon: 'Grid' }
},
{
path: 'tenant-wechat-apps',
name: 'TenantWechatApps',
component: () => import('@/views/tenant-wechat-apps/index.vue'),
meta: { title: '企微应用', icon: 'ChatDotRound' }
},
{
path: 'app-config',
name: 'AppConfig',
component: () => import('@/views/app-config/index.vue'),
meta: { title: '租户应用配置', icon: 'Setting' }
},
{
path: 'stats',
name: 'Stats',
component: () => import('@/views/stats/index.vue'),
meta: { title: '统计分析', icon: 'TrendCharts' }
},
{
path: 'logs',
name: 'Logs',
component: () => import('@/views/logs/index.vue'),
meta: { title: '日志查看', icon: 'Document' }
},
{
path: 'users',
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' }
}
]
}
]
const router = createRouter({
history: createWebHistory(),
routes
})
// 路由守卫
router.beforeEach((to, from, next) => {
// 设置页面标题
document.title = to.meta.title ? `${to.meta.title} - 平台管理` : '平台管理'
// 检查登录状态
const authStore = useAuthStore()
if (to.meta.public) {
next()
} else if (!authStore.isLoggedIn) {
next('/login')
} else if (to.meta.role && authStore.user?.role !== to.meta.role) {
next('/dashboard')
} else {
next()
}
})
export default router

View File

@@ -0,0 +1,60 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import api from '@/api'
export const useAuthStore = defineStore('auth', () => {
const token = ref('')
const user = ref(null)
const isLoggedIn = computed(() => !!token.value)
const isAdmin = computed(() => user.value?.role === 'admin')
const isOperator = computed(() => ['admin', 'operator'].includes(user.value?.role))
function initFromStorage() {
const savedToken = localStorage.getItem('token')
const savedUser = localStorage.getItem('user')
if (savedToken) {
token.value = savedToken
api.defaults.headers.common['Authorization'] = `Bearer ${savedToken}`
}
if (savedUser) {
try {
user.value = JSON.parse(savedUser)
} catch (e) {
// ignore
}
}
}
async function login(username, password) {
const response = await api.post('/api/auth/login', { username, password })
if (response.data.success) {
token.value = response.data.token
user.value = response.data.user
localStorage.setItem('token', token.value)
localStorage.setItem('user', JSON.stringify(user.value))
api.defaults.headers.common['Authorization'] = `Bearer ${token.value}`
return true
}
throw new Error(response.data.error || '登录失败')
}
function logout() {
token.value = ''
user.value = null
localStorage.removeItem('token')
localStorage.removeItem('user')
delete api.defaults.headers.common['Authorization']
}
return {
token,
user,
isLoggedIn,
isAdmin,
isOperator,
initFromStorage,
login,
logout
}
})

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,411 @@
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Delete, Plus } from '@element-plus/icons-vue'
import api from '@/api'
import { useAuthStore } from '@/stores/auth'
const authStore = useAuthStore()
const loading = ref(false)
const tableData = ref([])
const total = ref(0)
const query = reactive({
page: 1,
size: 20
})
// 配置项类型选项
const configTypes = [
{ value: 'text', label: '文本输入' },
{ value: 'radio', label: '单选' },
{ value: 'select', label: '下拉选择' },
{ value: 'switch', label: '开关' }
]
// 对话框
const dialogVisible = ref(false)
const dialogTitle = ref('')
const editingId = ref(null)
const formRef = ref(null)
const form = reactive({
app_code: '',
app_name: '',
base_url: '',
description: '',
require_jssdk: false,
config_schema: [] // 配置项定义
})
const rules = {
app_code: [{ required: true, message: '请输入应用代码', trigger: 'blur' }],
app_name: [{ required: true, message: '请输入应用名称', trigger: 'blur' }]
}
async function fetchList() {
loading.value = true
try {
const res = await api.get('/api/apps', { params: query })
tableData.value = res.data.items || []
total.value = res.data.total || 0
} catch (e) {
console.error(e)
} finally {
loading.value = false
}
}
function handleSearch() {
query.page = 1
fetchList()
}
function handlePageChange(page) {
query.page = page
fetchList()
}
function handleCreate() {
editingId.value = null
dialogTitle.value = '新建应用'
Object.assign(form, {
app_code: '',
app_name: '',
base_url: '',
description: '',
require_jssdk: false,
config_schema: []
})
dialogVisible.value = true
}
function handleEdit(row) {
editingId.value = row.id
dialogTitle.value = '编辑应用'
Object.assign(form, {
app_code: row.app_code,
app_name: row.app_name,
base_url: row.base_url || '',
description: row.description || '',
require_jssdk: row.require_jssdk || false,
config_schema: row.config_schema ? row.config_schema.map(c => ({
...c,
options: c.options || [],
option_labels: c.option_labels || {}
})) : []
})
dialogVisible.value = true
}
// 配置项管理
function addConfigItem() {
form.config_schema.push({
key: '',
label: '',
type: 'text',
options: [],
option_labels: {},
default: '',
placeholder: '',
required: false
})
}
function removeConfigItem(index) {
form.config_schema.splice(index, 1)
}
// 选项管理radio/select 类型)
function addOption(config) {
const optionKey = `option_${config.options.length + 1}`
config.options.push(optionKey)
config.option_labels[optionKey] = ''
}
function removeOption(config, index) {
const optionKey = config.options[index]
config.options.splice(index, 1)
delete config.option_labels[optionKey]
}
function updateOptionKey(config, index, newKey) {
const oldKey = config.options[index]
const oldLabel = config.option_labels[oldKey]
delete config.option_labels[oldKey]
config.options[index] = newKey
config.option_labels[newKey] = oldLabel || ''
}
async function handleSubmit() {
await formRef.value.validate()
const data = { ...form }
try {
if (editingId.value) {
await api.put(`/api/apps/${editingId.value}`, data)
ElMessage.success('更新成功')
} else {
await api.post('/api/apps', data)
ElMessage.success('创建成功')
}
dialogVisible.value = false
fetchList()
} catch (e) {
// 错误已在拦截器处理
}
}
async function handleDelete(row) {
await ElMessageBox.confirm(`确定删除应用 "${row.app_name}" 吗?`, '提示', {
type: 'warning'
})
try {
await api.delete(`/api/apps/${row.id}`)
ElMessage.success('删除成功')
fetchList()
} catch (e) {
// 错误已在拦截器处理
}
}
async function handleToggleStatus(row) {
const newStatus = row.status === 1 ? 0 : 1
try {
await api.put(`/api/apps/${row.id}`, { status: newStatus })
ElMessage.success(newStatus === 1 ? '已启用' : '已禁用')
fetchList()
} catch (e) {
// 错误已在拦截器处理
}
}
onMounted(() => {
fetchList()
})
</script>
<template>
<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>
<div class="page-tip">
<el-alert type="info" :closable="false">
应用管理每个应用是一个独立的服务有独立的访问地址
租户订阅应用后平台生成 Token 供应用鉴权使用
</el-alert>
</div>
<!-- 表格 -->
<el-table v-loading="loading" :data="tableData" style="width: 100%">
<el-table-column prop="id" label="ID" width="60" />
<el-table-column prop="app_code" label="应用代码" width="150" />
<el-table-column prop="app_name" label="应用名称" width="180" />
<el-table-column prop="base_url" label="访问地址" min-width="300" show-overflow-tooltip />
<el-table-column label="配置项" width="90">
<template #default="{ row }">
<el-tag v-if="row.config_schema && row.config_schema.length > 0" type="primary" size="small">
{{ row.config_schema.length }}
</el-tag>
<span v-else style="color: #909399; font-size: 12px">-</span>
</template>
</el-table-column>
<el-table-column label="JS-SDK" width="90">
<template #default="{ row }">
<el-tag :type="row.require_jssdk ? 'warning' : 'info'" size="small">
{{ row.require_jssdk ? '需要' : '不需要' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="状态" width="80">
<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="200" 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="row.status === 1 ? 'warning' : 'success'" link size="small" @click="handleToggleStatus(row)">
{{ row.status === 1 ? '禁用' : '启用' }}
</el-button>
<el-button v-if="authStore.isOperator" type="danger" link size="small" @click="handleDelete(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<div style="margin-top: 20px; display: flex; justify-content: flex-end">
<el-pagination
v-model:current-page="query.page"
:page-size="query.size"
:total="total"
layout="total, prev, pager, next"
@current-change="handlePageChange"
/>
</div>
<!-- 编辑对话框 -->
<el-dialog v-model="dialogVisible" :title="dialogTitle" width="800px">
<el-form ref="formRef" :model="form" :rules="rules" label-width="100px">
<el-form-item label="应用代码" prop="app_code">
<el-input v-model="form.app_code" :disabled="!!editingId" placeholder="唯一标识,如: brainstorm" />
</el-form-item>
<el-form-item label="应用名称" prop="app_name">
<el-input v-model="form.app_name" placeholder="显示名称,如: 头脑风暴" />
</el-form-item>
<el-form-item label="访问地址">
<el-input v-model="form.base_url" placeholder="如: https://brainstorm.example.com" />
<div style="color: #909399; font-size: 12px; margin-top: 4px">
应用的访问地址用于生成链接和跳转
</div>
</el-form-item>
<el-form-item label="描述">
<el-input v-model="form.description" type="textarea" :rows="2" placeholder="应用描述" />
</el-form-item>
<el-form-item label="企微JS-SDK">
<el-switch v-model="form.require_jssdk" />
<span style="margin-left: 12px; color: #909399; font-size: 12px">开启后租户需关联企微应用</span>
</el-form-item>
<!-- 配置项定义 -->
<el-divider content-position="left">配置项定义</el-divider>
<div class="config-schema-section">
<div class="config-schema-tip">
定义租户订阅时可配置的参数如行业类型提示词等
</div>
<div v-for="(config, index) in form.config_schema" :key="index" class="config-schema-item">
<div class="config-header">
<span class="config-index">#{{ index + 1 }}</span>
<el-button type="danger" :icon="Delete" circle size="small" @click="removeConfigItem(index)" />
</div>
<div class="config-row">
<el-input v-model="config.key" placeholder="配置键(如:industry)" style="width: 140px" />
<el-input v-model="config.label" placeholder="显示标签(如:行业类型)" style="width: 160px" />
<el-select v-model="config.type" placeholder="类型" style="width: 120px">
<el-option v-for="t in configTypes" :key="t.value" :label="t.label" :value="t.value" />
</el-select>
<el-checkbox v-model="config.required">必填</el-checkbox>
</div>
<!-- text 类型显示 placeholder -->
<div v-if="config.type === 'text'" class="config-row" style="margin-top: 8px">
<el-input v-model="config.placeholder" placeholder="输入提示文字" style="width: 300px" />
<el-input v-model="config.default" placeholder="默认值" style="width: 200px" />
</div>
<!-- switch 类型显示默认值 -->
<div v-if="config.type === 'switch'" class="config-row" style="margin-top: 8px">
<span style="color: #606266; margin-right: 8px">默认值</span>
<el-switch v-model="config.default" active-value="true" inactive-value="false" />
</div>
<!-- radio/select 类型显示选项编辑 -->
<div v-if="config.type === 'radio' || config.type === 'select'" class="config-options">
<div class="options-label">选项列表</div>
<div v-for="(opt, optIndex) in config.options" :key="optIndex" class="option-row">
<el-input
:model-value="opt"
@update:model-value="v => updateOptionKey(config, optIndex, v)"
placeholder="选项值(:medical)"
style="width: 140px"
/>
<el-input
v-model="config.option_labels[opt]"
placeholder="显示名(:医美)"
style="width: 140px"
/>
<el-radio v-model="config.default" :value="opt">默认</el-radio>
<el-button type="danger" :icon="Delete" circle size="small" @click="removeOption(config, optIndex)" />
</div>
<el-button type="primary" plain size="small" @click="addOption(config)">
<el-icon><Plus /></el-icon> 添加选项
</el-button>
</div>
</div>
<el-button type="primary" plain @click="addConfigItem" style="margin-top: 12px">
<el-icon><Plus /></el-icon> 添加配置项
</el-button>
</div>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="handleSubmit">确定</el-button>
</template>
</el-dialog>
</div>
</template>
<style scoped>
.page-tip {
margin-bottom: 16px;
}
/* 配置项定义样式 */
.config-schema-section {
padding: 0 10px;
}
.config-schema-tip {
color: #909399;
font-size: 13px;
margin-bottom: 12px;
}
.config-schema-item {
background: #f5f7fa;
border-radius: 8px;
padding: 12px;
margin-bottom: 12px;
}
.config-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 10px;
}
.config-index {
font-weight: 600;
color: #409eff;
}
.config-row {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.config-options {
margin-top: 10px;
padding: 10px;
background: #fff;
border-radius: 6px;
}
.options-label {
font-size: 13px;
color: #606266;
margin-bottom: 8px;
}
.option-row {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
</style>

View File

@@ -0,0 +1,241 @@
<script setup>
import { ref, onMounted, onUnmounted } from 'vue'
import * as echarts from 'echarts'
import api from '@/api'
const stats = ref({
totalTenants: 0,
activeTenants: 0,
todayCalls: 0,
todayTokens: 0,
weekCalls: 0,
weekTokens: 0
})
const recentLogs = ref([])
const trendData = ref([])
const chartRef = ref(null)
const chartLoading = ref(false)
let chartInstance = null
async function fetchStats() {
try {
// 获取租户统计
const tenantsRes = await api.get('/api/tenants', { params: { size: 1 } })
stats.value.totalTenants = tenantsRes.data.total || 0
// 获取统计数据
const statsRes = await api.get('/api/stats/summary')
if (statsRes.data) {
stats.value.todayCalls = statsRes.data.today_calls || 0
stats.value.todayTokens = statsRes.data.today_tokens || 0
stats.value.weekCalls = statsRes.data.week_calls || 0
stats.value.weekTokens = statsRes.data.week_tokens || 0
}
} catch (e) {
console.error('获取统计失败:', e)
}
}
async function fetchRecentLogs() {
try {
const res = await api.get('/api/logs', { params: { size: 10, log_type: 'request' } })
recentLogs.value = res.data.items || []
} catch (e) {
console.error('获取日志失败:', e)
}
}
async function fetchTrendData() {
chartLoading.value = true
try {
const res = await api.get('/api/stats/trend', { params: { days: 7 } })
trendData.value = res.data.trend || []
updateChart()
} catch (e) {
console.error('获取趋势数据失败:', e)
// 如果API失败使用空数据
trendData.value = []
updateChart()
} finally {
chartLoading.value = false
}
}
function initChart() {
if (!chartRef.value) return
chartInstance = echarts.init(chartRef.value)
}
function updateChart() {
if (!chartInstance) return
// 从API数据提取日期和调用次数
const dates = trendData.value.map(item => {
// 格式化日期为 MM-DD
const date = new Date(item.date)
return `${(date.getMonth() + 1).toString().padStart(2, '0')}-${date.getDate().toString().padStart(2, '0')}`
})
const calls = trendData.value.map(item => item.calls || 0)
const tokens = trendData.value.map(item => item.tokens || 0)
const option = {
title: {
text: '近7天 AI 调用趋势',
textStyle: { fontSize: 14, fontWeight: 500 }
},
tooltip: {
trigger: 'axis',
formatter: function(params) {
let result = params[0].axisValue + '<br/>'
params.forEach(param => {
result += `${param.marker} ${param.seriesName}: ${param.value.toLocaleString()}<br/>`
})
return result
}
},
legend: {
data: ['调用次数', 'Token 消耗'],
top: 0,
right: 0
},
grid: {
left: '3%',
right: '4%',
bottom: '3%',
top: 50,
containLabel: true
},
xAxis: {
type: 'category',
boundaryGap: false,
data: dates.length > 0 ? dates : ['暂无数据']
},
yAxis: [
{
type: 'value',
name: '调用次数',
position: 'left'
},
{
type: 'value',
name: 'Token',
position: 'right'
}
],
series: [
{
name: '调用次数',
type: 'line',
smooth: true,
areaStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: 'rgba(64, 158, 255, 0.3)' },
{ offset: 1, color: 'rgba(64, 158, 255, 0.05)' }
])
},
lineStyle: { color: '#409eff' },
itemStyle: { color: '#409eff' },
data: calls.length > 0 ? calls : [0]
},
{
name: 'Token 消耗',
type: 'line',
yAxisIndex: 1,
smooth: true,
lineStyle: { color: '#67c23a' },
itemStyle: { color: '#67c23a' },
data: tokens.length > 0 ? tokens : [0]
}
]
}
chartInstance.setOption(option)
}
function handleResize() {
chartInstance?.resize()
}
onMounted(() => {
fetchStats()
fetchRecentLogs()
initChart()
fetchTrendData()
window.addEventListener('resize', handleResize)
})
onUnmounted(() => {
window.removeEventListener('resize', handleResize)
chartInstance?.dispose()
})
</script>
<template>
<div class="dashboard">
<!-- 统计卡片 -->
<div class="stat-cards">
<div class="stat-card">
<div class="stat-title">租户总数</div>
<div class="stat-value">{{ stats.totalTenants }}</div>
</div>
<div class="stat-card">
<div class="stat-title">今日 AI 调用</div>
<div class="stat-value">{{ stats.todayCalls.toLocaleString() }}</div>
</div>
<div class="stat-card">
<div class="stat-title">今日 Token 消耗</div>
<div class="stat-value">{{ stats.todayTokens.toLocaleString() }}</div>
</div>
<div class="stat-card">
<div class="stat-title">本周 AI 调用</div>
<div class="stat-value">{{ stats.weekCalls.toLocaleString() }}</div>
</div>
</div>
<!-- 图表区域 -->
<div class="chart-section" v-loading="chartLoading">
<div class="chart-container" ref="chartRef"></div>
</div>
<!-- 最近日志 -->
<div class="page-container" style="margin-top: 20px">
<div class="page-header">
<div class="title">最近请求日志</div>
</div>
<el-table :data="recentLogs" style="width: 100%" size="small">
<el-table-column prop="app_code" label="应用" width="100" />
<el-table-column prop="path" label="路径" min-width="200" show-overflow-tooltip />
<el-table-column prop="method" label="方法" width="80" />
<el-table-column prop="status_code" label="状态" width="80">
<template #default="{ row }">
<el-tag :type="row.status_code < 400 ? 'success' : 'danger'" size="small">
{{ row.status_code }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="duration_ms" label="耗时" width="100">
<template #default="{ row }">
{{ row.duration_ms }}ms
</template>
</el-table-column>
<el-table-column prop="log_time" label="时间" width="180" />
</el-table>
</div>
</div>
</template>
<style lang="scss" scoped>
.dashboard {
.chart-section {
background: #fff;
border-radius: 8px;
padding: 20px;
}
.chart-container {
height: 300px;
}
}
</style>

View File

@@ -0,0 +1,223 @@
<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,137 @@
<script setup>
import { ref, reactive } from 'vue'
import { useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import { ElMessage } from 'element-plus'
const router = useRouter()
const authStore = useAuthStore()
const loading = ref(false)
const form = reactive({
username: '',
password: ''
})
const rules = {
username: [{ required: true, message: '请输入用户名', trigger: 'blur' }],
password: [{ required: true, message: '请输入密码', trigger: 'blur' }]
}
const formRef = ref(null)
async function handleLogin() {
await formRef.value.validate()
loading.value = true
try {
await authStore.login(form.username, form.password)
ElMessage.success('登录成功')
router.push('/dashboard')
} catch (error) {
ElMessage.error(error.message || '登录失败')
} finally {
loading.value = false
}
}
</script>
<template>
<div class="login-container">
<div class="login-card">
<div class="login-header">
<h1>平台管理后台</h1>
<p>统一管理租户应用与数据</p>
</div>
<el-form
ref="formRef"
:model="form"
:rules="rules"
label-width="0"
size="large"
@submit.prevent="handleLogin"
>
<el-form-item prop="username">
<el-input
v-model="form.username"
placeholder="用户名"
prefix-icon="User"
/>
</el-form-item>
<el-form-item prop="password">
<el-input
v-model="form.password"
type="password"
placeholder="密码"
prefix-icon="Lock"
show-password
@keyup.enter="handleLogin"
/>
</el-form-item>
<el-form-item>
<el-button
type="primary"
:loading="loading"
style="width: 100%"
@click="handleLogin"
>
登录
</el-button>
</el-form-item>
</el-form>
<div class="login-footer">
<p>默认账号: admin / admin123</p>
</div>
</div>
</div>
</template>
<style lang="scss" scoped>
.login-container {
height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
.login-card {
width: 400px;
padding: 40px;
background: #fff;
border-radius: 12px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
}
.login-header {
text-align: center;
margin-bottom: 32px;
h1 {
font-size: 24px;
color: #303133;
margin: 0 0 8px;
}
p {
font-size: 14px;
color: #909399;
margin: 0;
}
}
.login-footer {
text-align: center;
margin-top: 20px;
p {
font-size: 12px;
color: #c0c4cc;
margin: 0;
}
}
</style>

View File

@@ -0,0 +1,236 @@
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import api from '@/api'
const loading = ref(false)
const tableData = ref([])
const total = ref(0)
const query = reactive({
page: 1,
size: 20,
log_type: '',
level: '',
app_code: '',
trace_id: '',
keyword: ''
})
// 详情对话框
const detailVisible = ref(false)
const currentLog = ref(null)
async function fetchList() {
loading.value = true
try {
const params = { ...query }
// 移除空值
Object.keys(params).forEach(key => {
if (params[key] === '') delete params[key]
})
const res = await api.get('/api/logs', { params })
tableData.value = res.data.items || []
total.value = res.data.total || 0
} catch (e) {
console.error('获取日志失败:', e)
} finally {
loading.value = false
}
}
function handleSearch() {
query.page = 1
fetchList()
}
function handleReset() {
Object.assign(query, {
page: 1,
size: 20,
log_type: '',
level: '',
app_code: '',
trace_id: '',
keyword: ''
})
fetchList()
}
function handlePageChange(page) {
query.page = page
fetchList()
}
function showDetail(row) {
currentLog.value = row
detailVisible.value = true
}
function getLevelType(level) {
const map = {
debug: 'info',
info: 'success',
warning: 'warning',
error: 'danger'
}
return map[level] || 'info'
}
function getLogTypeText(type) {
const map = {
request: '请求日志',
error: '错误日志',
app: '应用日志',
biz: '业务日志',
audit: '审计日志'
}
return map[type] || type
}
function formatJson(obj) {
if (!obj) return ''
try {
if (typeof obj === 'string') {
obj = JSON.parse(obj)
}
return JSON.stringify(obj, null, 2)
} catch {
return String(obj)
}
}
onMounted(() => {
fetchList()
})
</script>
<template>
<div class="page-container">
<div class="page-header">
<div class="title">日志查看</div>
</div>
<!-- 搜索栏 -->
<div class="search-bar">
<el-select v-model="query.log_type" placeholder="日志类型" clearable style="width: 120px">
<el-option label="请求日志" value="request" />
<el-option label="错误日志" value="error" />
<el-option label="应用日志" value="app" />
<el-option label="业务日志" value="biz" />
<el-option label="审计日志" value="audit" />
</el-select>
<el-select v-model="query.level" placeholder="级别" clearable style="width: 100px">
<el-option label="DEBUG" value="debug" />
<el-option label="INFO" value="info" />
<el-option label="WARNING" value="warning" />
<el-option label="ERROR" value="error" />
</el-select>
<el-input
v-model="query.app_code"
placeholder="应用代码"
clearable
style="width: 120px"
/>
<el-input
v-model="query.trace_id"
placeholder="Trace ID"
clearable
style="width: 200px"
/>
<el-input
v-model="query.keyword"
placeholder="关键词搜索"
clearable
style="width: 180px"
@keyup.enter="handleSearch"
/>
<el-button type="primary" @click="handleSearch">搜索</el-button>
<el-button @click="handleReset">重置</el-button>
</div>
<!-- 表格 -->
<el-table v-loading="loading" :data="tableData" style="width: 100%">
<el-table-column prop="log_type" label="类型" width="100">
<template #default="{ row }">
{{ getLogTypeText(row.log_type) }}
</template>
</el-table-column>
<el-table-column prop="level" label="级别" width="80">
<template #default="{ row }">
<el-tag :type="getLevelType(row.level)" size="small">
{{ row.level?.toUpperCase() }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="app_code" label="应用" width="100" />
<el-table-column prop="message" label="消息" min-width="250" show-overflow-tooltip />
<el-table-column prop="trace_id" label="Trace ID" width="140" show-overflow-tooltip />
<el-table-column prop="path" label="路径" width="150" show-overflow-tooltip />
<el-table-column prop="status_code" label="状态码" width="80">
<template #default="{ row }">
<el-tag v-if="row.status_code" :type="row.status_code < 400 ? 'success' : 'danger'" size="small">
{{ row.status_code }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="duration_ms" label="耗时" width="80">
<template #default="{ row }">
{{ row.duration_ms ? row.duration_ms + 'ms' : '-' }}
</template>
</el-table-column>
<el-table-column prop="log_time" label="时间" width="180" />
<el-table-column label="操作" width="80" fixed="right">
<template #default="{ row }">
<el-button type="primary" link size="small" @click="showDetail(row)">详情</el-button>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<div style="margin-top: 20px; display: flex; justify-content: flex-end">
<el-pagination
v-model:current-page="query.page"
:page-size="query.size"
:total="total"
layout="total, prev, pager, next"
@current-change="handlePageChange"
/>
</div>
<!-- 详情对话框 -->
<el-dialog v-model="detailVisible" title="日志详情" width="700px">
<template v-if="currentLog">
<el-descriptions :column="2" border>
<el-descriptions-item label="ID">{{ currentLog.id }}</el-descriptions-item>
<el-descriptions-item label="类型">{{ getLogTypeText(currentLog.log_type) }}</el-descriptions-item>
<el-descriptions-item label="级别">
<el-tag :type="getLevelType(currentLog.level)" size="small">
{{ currentLog.level?.toUpperCase() }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="应用">{{ currentLog.app_code || '-' }}</el-descriptions-item>
<el-descriptions-item label="租户">{{ currentLog.tenant_id || '-' }}</el-descriptions-item>
<el-descriptions-item label="Trace ID">{{ currentLog.trace_id || '-' }}</el-descriptions-item>
<el-descriptions-item label="路径" :span="2">{{ currentLog.path || '-' }}</el-descriptions-item>
<el-descriptions-item label="方法">{{ currentLog.method || '-' }}</el-descriptions-item>
<el-descriptions-item label="状态码">{{ currentLog.status_code || '-' }}</el-descriptions-item>
<el-descriptions-item label="耗时">{{ currentLog.duration_ms ? currentLog.duration_ms + 'ms' : '-' }}</el-descriptions-item>
<el-descriptions-item label="IP">{{ currentLog.ip_address || '-' }}</el-descriptions-item>
<el-descriptions-item label="时间" :span="2">{{ currentLog.log_time }}</el-descriptions-item>
<el-descriptions-item label="消息" :span="2">{{ currentLog.message || '-' }}</el-descriptions-item>
</el-descriptions>
<div v-if="currentLog.extra_data" style="margin-top: 16px">
<div style="font-weight: 500; margin-bottom: 8px">附加数据:</div>
<pre style="background: #f5f7fa; padding: 12px; border-radius: 4px; overflow: auto; max-height: 300px">{{ formatJson(currentLog.extra_data) }}</pre>
</div>
<div v-if="currentLog.stack_trace" style="margin-top: 16px">
<div style="font-weight: 500; margin-bottom: 8px">堆栈信息:</div>
<pre style="background: #fef0f0; color: #f56c6c; padding: 12px; border-radius: 4px; overflow: auto; max-height: 300px">{{ currentLog.stack_trace }}</pre>
</div>
</template>
</el-dialog>
</div>
</template>

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

@@ -0,0 +1,181 @@
<script setup>
import { ref, reactive, onMounted, onUnmounted } from 'vue'
import * as echarts from 'echarts'
import api from '@/api'
import dayjs from 'dayjs'
const loading = ref(false)
const query = reactive({
tenant_id: '',
app_code: '',
start_date: dayjs().subtract(7, 'day').format('YYYY-MM-DD'),
end_date: dayjs().format('YYYY-MM-DD')
})
const stats = ref({
total_calls: 0,
total_tokens: 0,
total_cost: 0
})
const dailyData = ref([])
const chartRef = ref(null)
let chartInstance = null
async function fetchStats() {
loading.value = true
try {
const res = await api.get('/api/stats/daily', { params: query })
dailyData.value = res.data.items || []
// 计算汇总
let totalCalls = 0, totalTokens = 0, totalCost = 0
dailyData.value.forEach(item => {
totalCalls += item.ai_calls || 0
totalTokens += item.ai_tokens || 0
totalCost += parseFloat(item.ai_cost) || 0
})
stats.value = { total_calls: totalCalls, total_tokens: totalTokens, total_cost: totalCost }
updateChart()
} catch (e) {
console.error(e)
} finally {
loading.value = false
}
}
function updateChart() {
if (!chartInstance) return
const dates = dailyData.value.map(d => d.stat_date)
const calls = dailyData.value.map(d => d.ai_calls || 0)
const tokens = dailyData.value.map(d => d.ai_tokens || 0)
chartInstance.setOption({
title: { text: 'AI 调用趋势' },
tooltip: { trigger: 'axis' },
legend: { data: ['调用次数', 'Token 消耗'], top: 30 },
grid: { left: '3%', right: '4%', bottom: '3%', top: 80, containLabel: true },
xAxis: { type: 'category', data: dates },
yAxis: [
{ type: 'value', name: '调用次数' },
{ type: 'value', name: 'Token' }
],
series: [
{
name: '调用次数',
type: 'bar',
data: calls,
itemStyle: { color: '#409eff' }
},
{
name: 'Token 消耗',
type: 'line',
yAxisIndex: 1,
data: tokens,
smooth: true,
itemStyle: { color: '#67c23a' }
}
]
})
}
function initChart() {
if (!chartRef.value) return
chartInstance = echarts.init(chartRef.value)
}
function handleSearch() {
fetchStats()
}
function handleResize() {
chartInstance?.resize()
}
onMounted(() => {
initChart()
fetchStats()
window.addEventListener('resize', handleResize)
})
onUnmounted(() => {
window.removeEventListener('resize', handleResize)
chartInstance?.dispose()
})
</script>
<template>
<div class="page-container">
<div class="page-header">
<div class="title">统计分析</div>
</div>
<!-- 搜索栏 -->
<div class="search-bar">
<el-input v-model="query.tenant_id" placeholder="租户ID" clearable style="width: 160px" />
<el-select v-model="query.app_code" placeholder="应用" clearable style="width: 120px">
<el-option label="全部" value="" />
<el-option label="tools" value="tools" />
<el-option label="interview" value="interview" />
</el-select>
<el-date-picker
v-model="query.start_date"
type="date"
placeholder="开始日期"
value-format="YYYY-MM-DD"
style="width: 150px"
/>
<span style="color: #909399"></span>
<el-date-picker
v-model="query.end_date"
type="date"
placeholder="结束日期"
value-format="YYYY-MM-DD"
style="width: 150px"
/>
<el-button type="primary" @click="handleSearch">查询</el-button>
</div>
<!-- 统计卡片 -->
<div class="stat-cards">
<div class="stat-card">
<div class="stat-title">AI 调用总次数</div>
<div class="stat-value">{{ stats.total_calls.toLocaleString() }}</div>
</div>
<div class="stat-card">
<div class="stat-title">Token 消耗总量</div>
<div class="stat-value">{{ stats.total_tokens.toLocaleString() }}</div>
</div>
<div class="stat-card">
<div class="stat-title">累计费用</div>
<div class="stat-value">¥{{ stats.total_cost.toFixed(2) }}</div>
</div>
</div>
<!-- 图表 -->
<div style="background: #fff; border-radius: 8px; padding: 20px; margin-bottom: 20px">
<div ref="chartRef" style="height: 350px" v-loading="loading"></div>
</div>
<!-- 数据表格 -->
<div style="background: #fff; border-radius: 8px; padding: 20px">
<h4 style="margin: 0 0 16px">日统计明细</h4>
<el-table :data="dailyData" style="width: 100%" v-loading="loading">
<el-table-column prop="stat_date" label="日期" width="120" />
<el-table-column prop="tenant_id" label="租户ID" width="120" />
<el-table-column prop="app_code" label="应用" width="100" />
<el-table-column prop="ai_calls" label="调用次数" width="120">
<template #default="{ row }">{{ (row.ai_calls || 0).toLocaleString() }}</template>
</el-table-column>
<el-table-column prop="ai_tokens" label="Token 消耗" width="150">
<template #default="{ row }">{{ (row.ai_tokens || 0).toLocaleString() }}</template>
</el-table-column>
<el-table-column prop="ai_cost" label="费用" width="100">
<template #default="{ row }">¥{{ parseFloat(row.ai_cost || 0).toFixed(4) }}</template>
</el-table-column>
</el-table>
</div>
</div>
</template>

View File

@@ -0,0 +1,300 @@
<script setup>
import { ref, reactive, onMounted, watch, computed } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import api from '@/api'
import { useAuthStore } from '@/stores/auth'
const authStore = useAuthStore()
const loading = ref(false)
const tableData = ref([])
const total = ref(0)
const query = reactive({
page: 1,
size: 20,
tenant_id: ''
})
// 租户列表(用于下拉选择)
const tenantList = ref([])
// 对话框
const dialogVisible = ref(false)
const dialogTitle = ref('')
const editingId = ref(null)
const formRef = ref(null)
const form = reactive({
tenant_id: '',
name: '',
corp_id: '',
agent_id: '',
secret: ''
})
// 根据选中的租户获取 corp_id
const selectedTenant = computed(() => {
return tenantList.value.find(t => t.code === form.tenant_id)
})
// 监听租户选择变化,自动填入 corp_id
watch(() => form.tenant_id, (newVal) => {
if (newVal && !editingId.value) {
const tenant = tenantList.value.find(t => t.code === newVal)
if (tenant) {
form.corp_id = tenant.corp_id || ''
}
}
})
const rules = {
tenant_id: [{ required: true, message: '请选择租户', trigger: 'change' }],
name: [{ required: true, message: '请输入应用名称', trigger: 'blur' }],
agent_id: [{ required: true, message: '请输入应用ID', trigger: 'blur' }]
}
// 获取租户列表
async function fetchTenants() {
try {
const res = await api.get('/api/tenants', { params: { size: 1000 } })
tenantList.value = res.data.items || []
} catch (e) {
console.error(e)
}
}
async function fetchList() {
loading.value = true
try {
const res = await api.get('/api/tenant-wechat-apps', { params: query })
tableData.value = res.data.items || []
total.value = res.data.total || 0
} catch (e) {
console.error(e)
} finally {
loading.value = false
}
}
function handleSearch() {
query.page = 1
fetchList()
}
function handlePageChange(page) {
query.page = page
fetchList()
}
function handleCreate() {
editingId.value = null
dialogTitle.value = '新建企微应用'
Object.assign(form, {
tenant_id: query.tenant_id || '',
name: '',
corp_id: '',
agent_id: '',
secret: ''
})
// 如果已选择租户,自动填入 corp_id
if (form.tenant_id) {
const tenant = tenantList.value.find(t => t.code === form.tenant_id)
if (tenant) {
form.corp_id = tenant.corp_id || ''
}
}
dialogVisible.value = true
}
function handleEdit(row) {
editingId.value = row.id
dialogTitle.value = '编辑企微应用'
Object.assign(form, {
tenant_id: row.tenant_id,
name: row.name,
corp_id: row.corp_id,
agent_id: row.agent_id,
secret: '' // 不回显密钥
})
dialogVisible.value = true
}
async function handleSubmit() {
await formRef.value.validate()
const data = { ...form }
// 如果没有输入新密钥,不传这个字段
if (!data.secret) {
delete data.secret
}
try {
if (editingId.value) {
await api.put(`/api/tenant-wechat-apps/${editingId.value}`, data)
ElMessage.success('更新成功')
} else {
await api.post('/api/tenant-wechat-apps', data)
ElMessage.success('创建成功')
}
dialogVisible.value = false
fetchList()
} catch (e) {
// 错误已在拦截器处理
}
}
async function handleDelete(row) {
await ElMessageBox.confirm(`确定删除企微应用「${row.name}」吗?`, '提示', {
type: 'warning'
})
try {
await api.delete(`/api/tenant-wechat-apps/${row.id}`)
ElMessage.success('删除成功')
fetchList()
} catch (e) {
// 错误已在拦截器处理
}
}
async function handleViewSecret(row) {
try {
const res = await api.get(`/api/tenant-wechat-apps/${row.id}/secret`)
if (res.data.secret) {
ElMessageBox.alert(res.data.secret, '应用 Secret', {
confirmButtonText: '关闭'
})
} else {
ElMessage.info('未配置 Secret')
}
} catch (e) {
// 错误已在拦截器处理
}
}
onMounted(() => {
fetchTenants()
fetchList()
})
</script>
<template>
<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>
<!-- 搜索栏 -->
<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-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 prop="tenant_id" label="租户ID" width="120" />
<el-table-column prop="name" label="应用名称" width="180" />
<el-table-column prop="corp_id" label="企业ID" width="200" show-overflow-tooltip />
<el-table-column prop="agent_id" label="应用ID" width="120" />
<el-table-column label="Secret" width="100">
<template #default="{ row }">
<el-tag v-if="row.has_secret" type="success" size="small">已配置</el-tag>
<el-tag v-else type="info" size="small">未配置</el-tag>
</template>
</el-table-column>
<el-table-column label="状态" width="80">
<template #default="{ row }">
<el-tag :type="row.status === 1 ? 'success' : 'danger'" size="small">
{{ row.status === 1 ? '启用' : '禁用' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="created_at" label="创建时间" width="180" />
<el-table-column label="操作" width="200" 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="warning" link size="small" @click="handleViewSecret(row)">查看密钥</el-button>
<el-button v-if="authStore.isOperator" type="danger" link size="small" @click="handleDelete(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<div style="margin-top: 20px; display: flex; justify-content: flex-end">
<el-pagination
v-model:current-page="query.page"
:page-size="query.size"
:total="total"
layout="total, prev, pager, next"
@current-change="handlePageChange"
/>
</div>
<!-- 编辑对话框 -->
<el-dialog v-model="dialogVisible" :title="dialogTitle" width="500px">
<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"
:disabled="!!editingId"
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="应用名称" prop="name">
<el-input v-model="form.name" placeholder="如: 工具集企微应用" />
</el-form-item>
<el-form-item label="企业ID">
<el-input
v-model="form.corp_id"
disabled
:placeholder="selectedTenant?.corp_id ? '' : '请先在租户管理中配置企业ID'"
/>
<div v-if="form.tenant_id && !selectedTenant?.corp_id" class="el-form-item__error" style="position: static;">
该租户未配置企业ID请先到租户管理中配置
</div>
</el-form-item>
<el-form-item label="应用ID" prop="agent_id">
<el-input v-model="form.agent_id" placeholder="自建应用的 AgentId" />
</el-form-item>
<el-form-item label="应用 Secret">
<el-input
v-model="form.secret"
type="password"
show-password
:placeholder="editingId ? '留空则不修改' : '应用的 Secret'"
/>
</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>

View File

@@ -0,0 +1,716 @@
<script setup>
import { ref, reactive, onMounted, computed, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import api from '@/api'
import { useAuthStore } from '@/stores/auth'
const authStore = useAuthStore()
const route = useRoute()
const router = useRouter()
const tenantId = route.params.id
const loading = ref(false)
const tenant = ref(null)
const activeTab = ref('basic')
// ========================================
// 基本信息
// ========================================
async function fetchDetail() {
loading.value = true
try {
const res = await api.get(`/api/tenants/${tenantId}`)
tenant.value = res.data
} catch (e) {
console.error(e)
} finally {
loading.value = false
}
}
function getStatusType(status) {
const map = { active: 'success', expired: 'danger', trial: 'warning' }
return map[status] || 'info'
}
function getStatusText(status) {
const map = { active: '活跃', expired: '已过期', trial: '试用' }
return map[status] || status
}
// ========================================
// 工具配置
// ========================================
const configLoading = ref(false)
const configList = ref([])
const configTypes = ref([])
const configKeyOptions = ref({})
const selectedTool = ref('__shared__') // 当前选择的工具
// 工具列表(租户级共享 + 各个工具)
const toolTabs = computed(() => {
const tabs = [{ code: '__shared__', name: '租户通用配置' }]
// 从配置列表中提取已有工具
const tools = new Set()
configList.value.forEach(c => {
if (c.tool_code && c.tool_code !== '__shared__') {
tools.add(c.tool_code)
}
})
// 预定义的工具列表
const predefinedTools = [
{ code: 'customer-profile', name: '客户画像' },
{ code: 'high-eq', name: '高情商回复' },
{ code: 'brainstorm', name: '头脑风暴' },
{ code: 'consultation-plan', name: '面诊方案' },
{ code: 'medical-compliance', name: '医疗合规' }
]
predefinedTools.forEach(t => {
tabs.push(t)
tools.delete(t.code)
})
// 添加其他已有配置的工具
tools.forEach(code => {
tabs.push({ code, name: code })
})
return tabs
})
// 当前工具的配置列表
const currentToolConfigs = computed(() => {
if (selectedTool.value === '__shared__') {
return configList.value.filter(c => !c.tool_code)
}
return configList.value.filter(c => c.tool_code === selectedTool.value)
})
// 按类型分组的配置
const groupedConfigs = computed(() => {
const groups = {}
currentToolConfigs.value.forEach(c => {
const type = c.config_type || 'params'
if (!groups[type]) {
groups[type] = []
}
groups[type].push(c)
})
return groups
})
// 获取类型名称
function getTypeName(type) {
const names = {
datasource: '数据源配置',
jssdk: 'JS-SDK 配置',
webhook: 'Webhook 配置',
params: '工具参数'
}
return names[type] || type
}
async function fetchToolConfigs() {
configLoading.value = true
try {
const res = await api.get('/api/tool-configs', {
params: { tenant_id: tenantId, size: 100 }
})
configList.value = res.data.items || []
} catch (e) {
console.error('获取配置失败:', e)
} finally {
configLoading.value = false
}
}
async function fetchConfigSchema() {
try {
const [typesRes, keysRes] = await Promise.all([
api.get('/api/tool-configs/schema/types'),
api.get('/api/tool-configs/schema/keys')
])
configTypes.value = typesRes.data.types || []
configKeyOptions.value = keysRes.data || {}
} catch (e) {
console.error('获取配置元数据失败:', e)
}
}
// 配置编辑对话框
const configDialogVisible = ref(false)
const configDialogTitle = ref('')
const editingConfigId = ref(null)
const configFormRef = ref(null)
const configForm = reactive({
config_type: 'params',
config_key: '',
config_value: '',
is_encrypted: 0,
description: ''
})
const configRules = {
config_type: [{ required: true, message: '请选择配置类型', trigger: 'change' }],
config_key: [{ required: true, message: '请输入配置键名', trigger: 'blur' }]
}
function handleAddConfig() {
editingConfigId.value = null
configDialogTitle.value = '添加配置'
Object.assign(configForm, {
config_type: 'params',
config_key: '',
config_value: '',
is_encrypted: 0,
description: ''
})
configDialogVisible.value = true
}
function handleEditConfig(config) {
editingConfigId.value = config.id
configDialogTitle.value = '编辑配置'
Object.assign(configForm, {
config_type: config.config_type,
config_key: config.config_key,
config_value: config.config_value || '',
is_encrypted: config.is_encrypted,
description: config.description || ''
})
configDialogVisible.value = true
}
async function handleConfigSubmit() {
await configFormRef.value.validate()
try {
if (editingConfigId.value) {
await api.put(`/api/tool-configs/${editingConfigId.value}`, {
config_value: configForm.config_value,
is_encrypted: configForm.is_encrypted,
description: configForm.description
})
ElMessage.success('更新成功')
} else {
await api.post('/api/tool-configs', {
tenant_id: tenantId,
tool_code: selectedTool.value === '__shared__' ? null : selectedTool.value,
config_type: configForm.config_type,
config_key: configForm.config_key,
config_value: configForm.config_value,
is_encrypted: configForm.is_encrypted,
description: configForm.description
})
ElMessage.success('添加成功')
}
configDialogVisible.value = false
fetchToolConfigs()
} catch (e) {
// 错误已在拦截器处理
}
}
async function handleDeleteConfig(config) {
await ElMessageBox.confirm(`确定删除配置「${config.config_key}」吗?`, '提示', {
type: 'warning'
})
try {
await api.delete(`/api/tool-configs/${config.id}`)
ElMessage.success('删除成功')
fetchToolConfigs()
} catch (e) {
// 错误已在拦截器处理
}
}
// 切换 Tab 时加载配置
watch(activeTab, (newVal) => {
if (newVal === 'config' && configList.value.length === 0) {
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()
})
</script>
<template>
<div class="page-container" v-loading="loading">
<div class="page-header">
<div class="title">
<el-button link @click="router.back()">
<el-icon><ArrowLeft /></el-icon>
</el-button>
租户详情 <span v-if="tenant" style="color: #909399; margin-left: 8px">{{ tenant.name }}</span>
</div>
</div>
<template v-if="tenant">
<el-tabs v-model="activeTab">
<!-- 基本信息 Tab -->
<el-tab-pane label="基本信息" name="basic">
<el-descriptions :column="2" border style="margin-bottom: 20px">
<el-descriptions-item label="租户ID">{{ tenant.id }}</el-descriptions-item>
<el-descriptions-item label="租户代码">{{ tenant.code }}</el-descriptions-item>
<el-descriptions-item label="租户名称">{{ tenant.name }}</el-descriptions-item>
<el-descriptions-item label="状态">
<el-tag :type="getStatusType(tenant.status)" size="small">
{{ getStatusText(tenant.status) }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="过期时间">{{ tenant.expired_at || '-' }}</el-descriptions-item>
<el-descriptions-item label="创建时间">{{ tenant.created_at }}</el-descriptions-item>
<el-descriptions-item label="联系人">{{ tenant.contact_info?.contact || '-' }}</el-descriptions-item>
<el-descriptions-item label="联系电话">{{ tenant.contact_info?.phone || '-' }}</el-descriptions-item>
</el-descriptions>
<!-- 用量统计 -->
<el-descriptions title="用量统计" :column="3" border style="margin-bottom: 20px">
<el-descriptions-item label="AI 调用总次数">
{{ tenant.usage_summary?.total_calls?.toLocaleString() || 0 }}
</el-descriptions-item>
<el-descriptions-item label="Token 消耗">
{{ tenant.usage_summary?.total_tokens?.toLocaleString() || 0 }}
</el-descriptions-item>
<el-descriptions-item label="累计费用">
¥{{ tenant.usage_summary?.total_cost?.toFixed(2) || '0.00' }}
</el-descriptions-item>
</el-descriptions>
<!-- 订阅信息 -->
<div style="margin-bottom: 20px">
<h4 style="margin-bottom: 12px">应用订阅</h4>
<el-table :data="tenant.subscriptions" style="width: 100%">
<el-table-column prop="app_code" label="应用" width="150" />
<el-table-column prop="start_date" label="开始日期" width="120" />
<el-table-column prop="end_date" label="结束日期" width="120" />
<el-table-column prop="quota" label="配额">
<template #default="{ row }">
{{ row.quota ? JSON.stringify(row.quota) : '-' }}
</template>
</el-table-column>
<el-table-column prop="status" label="状态" width="100">
<template #default="{ row }">
<el-tag :type="row.status === 'active' ? 'success' : 'danger'" size="small">
{{ row.status === 'active' ? '有效' : '已过期' }}
</el-tag>
</template>
</el-table-column>
</el-table>
<el-empty v-if="!tenant.subscriptions?.length" description="暂无订阅" />
</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">
<!-- 工具选择 Tabs -->
<div class="tool-tabs">
<el-radio-group v-model="selectedTool" size="small">
<el-radio-button
v-for="tool in toolTabs"
:key="tool.code"
:value="tool.code"
>
{{ tool.name }}
</el-radio-button>
</el-radio-group>
<el-button
v-if="authStore.isOperator"
type="primary"
size="small"
@click="handleAddConfig"
style="margin-left: 16px"
>
<el-icon><Plus /></el-icon>
添加配置
</el-button>
</div>
<!-- 配置列表按类型分组 -->
<div class="config-groups">
<template v-if="Object.keys(groupedConfigs).length > 0">
<div
v-for="(configs, type) in groupedConfigs"
:key="type"
class="config-group"
>
<div class="group-header">
<span class="group-title">{{ getTypeName(type) }}</span>
</div>
<el-table :data="configs" size="small">
<el-table-column prop="config_key" label="配置键" width="200" />
<el-table-column prop="config_value" label="配置值">
<template #default="{ row }">
<span v-if="row.is_encrypted" class="encrypted-value">******</span>
<span v-else>{{ row.config_value || '-' }}</span>
</template>
</el-table-column>
<el-table-column prop="description" label="说明" width="200" />
<el-table-column label="操作" width="150" fixed="right">
<template #default="{ row }">
<el-button
v-if="authStore.isOperator"
type="primary"
link
size="small"
@click="handleEditConfig(row)"
>编辑</el-button>
<el-button
v-if="authStore.isOperator"
type="danger"
link
size="small"
@click="handleDeleteConfig(row)"
>删除</el-button>
</template>
</el-table-column>
</el-table>
</div>
</template>
<el-empty
v-else
:description="`暂无${selectedTool === '__shared__' ? '租户通用' : '该工具'}配置`"
>
<el-button v-if="authStore.isOperator" type="primary" @click="handleAddConfig">
添加配置
</el-button>
</el-empty>
</div>
</div>
</el-tab-pane>
</el-tabs>
</template>
<!-- 配置编辑对话框 -->
<el-dialog v-model="configDialogVisible" :title="configDialogTitle" width="550px">
<el-form ref="configFormRef" :model="configForm" :rules="configRules" label-width="100px">
<el-form-item label="配置类型" prop="config_type">
<el-select
v-model="configForm.config_type"
:disabled="!!editingConfigId"
style="width: 100%"
>
<el-option
v-for="t in configTypes"
:key="t.code"
:label="t.name"
:value="t.code"
>
<span>{{ t.name }}</span>
<span style="color: #909399; font-size: 12px; margin-left: 8px">{{ t.description }}</span>
</el-option>
</el-select>
</el-form-item>
<el-form-item label="配置键" prop="config_key">
<el-autocomplete
v-model="configForm.config_key"
:fetch-suggestions="(query, cb) => {
const keys = configKeyOptions[configForm.config_type] || []
const results = query
? keys.filter(k => k.key.includes(query) || k.name.includes(query))
: keys
cb(results.map(k => ({ value: k.key, name: k.name })))
}"
:disabled="!!editingConfigId"
placeholder="输入或选择配置键"
style="width: 100%"
>
<template #default="{ item }">
<span>{{ item.value }}</span>
<span style="color: #909399; font-size: 12px; margin-left: 8px">{{ item.name }}</span>
</template>
</el-autocomplete>
</el-form-item>
<el-form-item label="配置值">
<el-input
v-model="configForm.config_value"
:type="configForm.is_encrypted ? 'password' : 'text'"
:show-password="configForm.is_encrypted"
placeholder="请输入配置值"
/>
</el-form-item>
<el-form-item label="加密存储">
<el-switch
v-model="configForm.is_encrypted"
:active-value="1"
:inactive-value="0"
/>
<span style="color: #909399; font-size: 12px; margin-left: 8px">
敏感信息(如密码、密钥)建议加密存储
</span>
</el-form-item>
<el-form-item label="说明">
<el-input
v-model="configForm.description"
type="textarea"
:rows="2"
placeholder="可选描述此配置的用途"
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="configDialogVisible = false">取消</el-button>
<el-button type="primary" @click="handleConfigSubmit">确定</el-button>
</template>
</el-dialog>
</div>
</template>
<style scoped>
.config-container {
padding: 0 4px;
}
.tool-tabs {
display: flex;
align-items: center;
margin-bottom: 20px;
flex-wrap: wrap;
gap: 8px;
}
.config-groups {
min-height: 200px;
}
.config-group {
margin-bottom: 24px;
}
.group-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
padding-bottom: 8px;
border-bottom: 1px solid #ebeef5;
}
.group-title {
font-weight: 600;
color: #303133;
font-size: 14px;
}
.encrypted-value {
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>

View File

@@ -0,0 +1,246 @@
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import api from '@/api'
import { useAuthStore } from '@/stores/auth'
const router = useRouter()
const authStore = useAuthStore()
const loading = ref(false)
const tableData = ref([])
const total = ref(0)
const query = reactive({
page: 1,
size: 20,
status: '',
keyword: ''
})
// 对话框
const dialogVisible = ref(false)
const dialogTitle = ref('')
const editingId = ref(null)
const formRef = ref(null)
const form = reactive({
code: '',
name: '',
corp_id: '',
status: 'active',
expired_at: null,
contact_info: {
contact: '',
phone: '',
email: ''
}
})
const rules = {
code: [{ required: true, message: '请输入租户代码', trigger: 'blur' }],
name: [{ required: true, message: '请输入租户名称', trigger: 'blur' }]
}
async function fetchList() {
loading.value = true
try {
const res = await api.get('/api/tenants', { params: query })
tableData.value = res.data.items || []
total.value = res.data.total || 0
} catch (e) {
console.error(e)
} finally {
loading.value = false
}
}
function handleSearch() {
query.page = 1
fetchList()
}
function handlePageChange(page) {
query.page = page
fetchList()
}
function handleCreate() {
editingId.value = null
dialogTitle.value = '新建租户'
Object.assign(form, {
code: '',
name: '',
corp_id: '',
status: 'active',
expired_at: null,
contact_info: { contact: '', phone: '', email: '' }
})
dialogVisible.value = true
}
function handleEdit(row) {
editingId.value = row.id
dialogTitle.value = '编辑租户'
Object.assign(form, {
code: row.code,
name: row.name,
corp_id: row.corp_id || '',
status: row.status,
expired_at: row.expired_at,
contact_info: row.contact_info || { contact: '', phone: '', email: '' }
})
dialogVisible.value = true
}
async function handleSubmit() {
await formRef.value.validate()
try {
if (editingId.value) {
await api.put(`/api/tenants/${editingId.value}`, form)
ElMessage.success('更新成功')
} else {
await api.post('/api/tenants', form)
ElMessage.success('创建成功')
}
dialogVisible.value = false
fetchList()
} catch (e) {
// 错误已在拦截器处理
}
}
async function handleDelete(row) {
await ElMessageBox.confirm(`确定删除租户 "${row.name}" 吗?`, '提示', {
type: 'warning'
})
try {
await api.delete(`/api/tenants/${row.id}`)
ElMessage.success('删除成功')
fetchList()
} catch (e) {
// 错误已在拦截器处理
}
}
function handleDetail(row) {
router.push(`/tenants/${row.id}`)
}
function getStatusType(status) {
const map = { active: 'success', expired: 'danger', trial: 'warning' }
return map[status] || 'info'
}
function getStatusText(status) {
const map = { active: '活跃', expired: '已过期', trial: '试用' }
return map[status] || status
}
onMounted(() => {
fetchList()
})
</script>
<template>
<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>
<!-- 搜索栏 -->
<div class="search-bar">
<el-input
v-model="query.keyword"
placeholder="搜索租户代码或名称"
clearable
style="width: 240px"
@keyup.enter="handleSearch"
/>
<el-select v-model="query.status" placeholder="状态" clearable style="width: 120px">
<el-option label="活跃" value="active" />
<el-option label="已过期" value="expired" />
<el-option label="试用" value="trial" />
</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="80" />
<el-table-column prop="code" label="代码" width="120" />
<el-table-column prop="name" label="名称" min-width="150" />
<el-table-column prop="corp_id" label="企业ID" width="200" show-overflow-tooltip />
<el-table-column prop="status" label="状态" width="100">
<template #default="{ row }">
<el-tag :type="getStatusType(row.status)" size="small">
{{ getStatusText(row.status) }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="expired_at" label="过期时间" width="120" />
<el-table-column prop="created_at" label="创建时间" width="180" />
<el-table-column label="操作" width="200" fixed="right">
<template #default="{ row }">
<el-button type="primary" link size="small" @click="handleDetail(row)">详情</el-button>
<el-button v-if="authStore.isOperator" type="primary" link size="small" @click="handleEdit(row)">编辑</el-button>
<el-button v-if="authStore.isOperator" type="danger" link size="small" @click="handleDelete(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<div style="margin-top: 20px; display: flex; justify-content: flex-end">
<el-pagination
v-model:current-page="query.page"
:page-size="query.size"
:total="total"
layout="total, prev, pager, next"
@current-change="handlePageChange"
/>
</div>
<!-- 编辑对话框 -->
<el-dialog v-model="dialogVisible" :title="dialogTitle" width="500px">
<el-form ref="formRef" :model="form" :rules="rules" label-width="100px">
<el-form-item label="租户代码" prop="code">
<el-input v-model="form.code" :disabled="!!editingId" placeholder="唯一标识" />
</el-form-item>
<el-form-item label="租户名称" prop="name">
<el-input v-model="form.name" placeholder="公司/组织名称" />
</el-form-item>
<el-form-item label="企业ID">
<el-input v-model="form.corp_id" placeholder="企业微信企业IDww开头" />
</el-form-item>
<el-form-item label="状态">
<el-select v-model="form.status" style="width: 100%">
<el-option label="活跃" value="active" />
<el-option label="试用" value="trial" />
<el-option label="已过期" value="expired" />
</el-select>
</el-form-item>
<el-form-item label="过期时间">
<el-date-picker v-model="form.expired_at" type="date" value-format="YYYY-MM-DD" style="width: 100%" />
</el-form-item>
<el-form-item label="联系人">
<el-input v-model="form.contact_info.contact" placeholder="联系人姓名" />
</el-form-item>
<el-form-item label="联系电话">
<el-input v-model="form.contact_info.phone" placeholder="联系电话" />
</el-form-item>
<el-form-item label="邮箱">
<el-input v-model="form.contact_info.email" 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>

View File

@@ -0,0 +1,169 @@
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import api from '@/api'
import { useAuthStore } from '@/stores/auth'
const authStore = useAuthStore()
const loading = ref(false)
const tableData = ref([])
// 对话框
const dialogVisible = ref(false)
const dialogTitle = ref('')
const formRef = ref(null)
const form = reactive({
username: '',
password: '',
nickname: '',
role: 'viewer'
})
const rules = {
username: [{ required: true, message: '请输入用户名', trigger: 'blur' }],
password: [{ required: true, message: '请输入密码', trigger: 'blur' }]
}
async function fetchList() {
loading.value = true
try {
const res = await api.get('/api/auth/users')
tableData.value = res.data || []
} catch (e) {
console.error(e)
} finally {
loading.value = false
}
}
function handleCreate() {
dialogTitle.value = '新建用户'
Object.assign(form, {
username: '',
password: '',
nickname: '',
role: 'viewer'
})
dialogVisible.value = true
}
async function handleSubmit() {
await formRef.value.validate()
try {
await api.post('/api/auth/users', form)
ElMessage.success('创建成功')
dialogVisible.value = false
fetchList()
} catch (e) {
// 错误已在拦截器处理
}
}
async function handleDelete(row) {
if (row.id === authStore.user?.id) {
ElMessage.warning('不能删除当前登录用户')
return
}
await ElMessageBox.confirm(`确定删除用户 "${row.username}" 吗?`, '提示', {
type: 'warning'
})
try {
await api.delete(`/api/auth/users/${row.id}`)
ElMessage.success('删除成功')
fetchList()
} catch (e) {
// 错误已在拦截器处理
}
}
function getRoleTag(role) {
const map = {
admin: { type: 'danger', text: '管理员' },
operator: { type: 'warning', text: '操作员' },
viewer: { type: 'info', text: '只读' }
}
return map[role] || { type: 'info', text: role }
}
onMounted(() => {
fetchList()
})
</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>
<!-- 表格 -->
<el-table v-loading="loading" :data="tableData" style="width: 100%">
<el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="username" label="用户名" width="150" />
<el-table-column prop="nickname" label="昵称" width="150" />
<el-table-column prop="role" label="角色" width="120">
<template #default="{ row }">
<el-tag :type="getRoleTag(row.role).type" size="small">
{{ getRoleTag(row.role).text }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="status" label="状态" width="100">
<template #default="{ row }">
<el-tag :type="row.status === 1 ? 'success' : 'danger'" size="small">
{{ row.status === 1 ? '启用' : '禁用' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="last_login_at" label="最后登录" width="180" />
<el-table-column prop="created_at" label="创建时间" width="180" />
<el-table-column label="操作" width="100" fixed="right">
<template #default="{ row }">
<el-button
type="danger"
link
size="small"
:disabled="row.id === authStore.user?.id"
@click="handleDelete(row)"
>
删除
</el-button>
</template>
</el-table-column>
</el-table>
<!-- 新建对话框 -->
<el-dialog v-model="dialogVisible" :title="dialogTitle" width="450px">
<el-form ref="formRef" :model="form" :rules="rules" label-width="80px">
<el-form-item label="用户名" prop="username">
<el-input v-model="form.username" placeholder="登录用户名" />
</el-form-item>
<el-form-item label="密码" prop="password">
<el-input v-model="form.password" type="password" show-password placeholder="登录密码" />
</el-form-item>
<el-form-item label="昵称">
<el-input v-model="form.nickname" placeholder="显示名称" />
</el-form-item>
<el-form-item label="角色">
<el-select v-model="form.role" style="width: 100%">
<el-option label="管理员" value="admin" />
<el-option label="操作员" value="operator" />
<el-option label="只读" value="viewer" />
</el-select>
</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>

25
frontend/vite.config.js Normal file
View File

@@ -0,0 +1,25 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { resolve } from 'path'
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
'@': resolve(__dirname, 'src')
}
},
server: {
port: 3001,
proxy: {
'/api': {
target: 'http://localhost:8001',
changeOrigin: true
}
}
},
build: {
outDir: 'dist',
sourcemap: false
}
})

View File

@@ -1,12 +1,25 @@
"""AI统计上报客户端"""
import os
import json
import asyncio
import logging
import threading
from datetime import datetime
from decimal import Decimal
from typing import Optional, List
from dataclasses import dataclass, asdict
from pathlib import Path
try:
import httpx
HTTPX_AVAILABLE = True
except ImportError:
HTTPX_AVAILABLE = False
from .trace import get_trace_id, get_tenant_id, get_user_id
logger = logging.getLogger(__name__)
@dataclass
class AICallEvent:
@@ -33,6 +46,24 @@ class AICallEvent:
if self.user_id is None:
self.user_id = get_user_id()
def to_dict(self) -> dict:
"""转换为可序列化的字典"""
return {
"tenant_id": self.tenant_id,
"app_code": self.app_code,
"module_code": self.module_code,
"prompt_name": self.prompt_name,
"model": self.model,
"input_tokens": self.input_tokens,
"output_tokens": self.output_tokens,
"cost": str(self.cost),
"latency_ms": self.latency_ms,
"status": self.status,
"user_id": self.user_id,
"trace_id": self.trace_id,
"event_time": self.event_time.isoformat() if self.event_time else None
}
class StatsClient:
"""统计上报客户端
@@ -51,23 +82,37 @@ class StatsClient:
)
"""
# 失败事件持久化文件
FAILED_EVENTS_FILE = ".platform_failed_events.json"
def __init__(
self,
tenant_id: int,
app_code: str,
platform_url: Optional[str] = None,
api_key: Optional[str] = None,
local_only: bool = True
local_only: bool = False,
max_retries: int = 3,
retry_delay: float = 1.0,
timeout: float = 10.0
):
self.tenant_id = tenant_id
self.app_code = app_code
self.platform_url = platform_url or os.getenv("PLATFORM_URL", "")
self.api_key = api_key or os.getenv("PLATFORM_API_KEY", "")
self.local_only = local_only or not self.platform_url
self.local_only = local_only or not self.platform_url or not HTTPX_AVAILABLE
self.max_retries = max_retries
self.retry_delay = retry_delay
self.timeout = timeout
# 批量上报缓冲区
self._buffer: List[AICallEvent] = []
self._buffer_size = 10 # 达到此数量时自动上报
self._lock = threading.Lock()
# 在启动时尝试发送之前失败的事件
if not self.local_only:
self._retry_failed_events()
def report_ai_call(
self,
@@ -113,36 +158,172 @@ class StatsClient:
user_id=user_id
)
self._buffer.append(event)
with self._lock:
self._buffer.append(event)
should_flush = flush or len(self._buffer) >= self._buffer_size
if flush or len(self._buffer) >= self._buffer_size:
if should_flush:
self.flush()
return event
def flush(self):
"""发送缓冲区中的所有事件"""
if not self._buffer:
return
events = self._buffer.copy()
self._buffer.clear()
with self._lock:
if not self._buffer:
return
events = self._buffer.copy()
self._buffer.clear()
if self.local_only:
# 本地模式:仅打印
for event in events:
print(f"[STATS] {event.app_code}/{event.module_code}: "
f"{event.prompt_name} - {event.input_tokens}+{event.output_tokens} tokens")
logger.info(f"[STATS] {event.app_code}/{event.module_code}: "
f"{event.prompt_name} - {event.input_tokens}+{event.output_tokens} tokens")
else:
# 远程上报
self._send_to_platform(events)
def _send_to_platform(self, events: List[AICallEvent]):
"""发送事件到平台(异步,后续实现)"""
# TODO: 使用httpx异步发送
pass
"""发送事件到平台"""
if not HTTPX_AVAILABLE:
logger.warning("httpx not installed, falling back to local mode")
return
# 转换事件为可序列化格式
payload = {"events": [e.to_dict() for e in events]}
# 尝试在事件循环中运行
try:
loop = asyncio.get_running_loop()
# 已在异步上下文中,创建任务
asyncio.create_task(self._send_async(payload, events))
except RuntimeError:
# 没有运行中的事件循环,使用同步方式
self._send_sync(payload, events)
def _send_sync(self, payload: dict, events: List[AICallEvent]):
"""同步发送事件"""
url = f"{self.platform_url.rstrip('/')}/api/stats/report/batch"
headers = {"X-API-Key": self.api_key, "Content-Type": "application/json"}
for attempt in range(self.max_retries):
try:
with httpx.Client(timeout=self.timeout) as client:
response = client.post(url, json=payload, headers=headers)
if response.status_code == 200:
result = response.json()
logger.debug(f"Stats reported successfully: {result.get('count', len(events))} events")
return
else:
logger.warning(f"Stats report failed with status {response.status_code}: {response.text}")
except httpx.TimeoutException:
logger.warning(f"Stats report timeout (attempt {attempt + 1}/{self.max_retries})")
except httpx.RequestError as e:
logger.warning(f"Stats report request error (attempt {attempt + 1}/{self.max_retries}): {e}")
except Exception as e:
logger.error(f"Stats report unexpected error: {e}")
break
# 重试延迟
if attempt < self.max_retries - 1:
import time
time.sleep(self.retry_delay * (attempt + 1))
# 所有重试都失败,持久化到文件
self._persist_failed_events(events)
async def _send_async(self, payload: dict, events: List[AICallEvent]):
"""异步发送事件"""
url = f"{self.platform_url.rstrip('/')}/api/stats/report/batch"
headers = {"X-API-Key": self.api_key, "Content-Type": "application/json"}
for attempt in range(self.max_retries):
try:
async with httpx.AsyncClient(timeout=self.timeout) as client:
response = await client.post(url, json=payload, headers=headers)
if response.status_code == 200:
result = response.json()
logger.debug(f"Stats reported successfully: {result.get('count', len(events))} events")
return
else:
logger.warning(f"Stats report failed with status {response.status_code}: {response.text}")
except httpx.TimeoutException:
logger.warning(f"Stats report timeout (attempt {attempt + 1}/{self.max_retries})")
except httpx.RequestError as e:
logger.warning(f"Stats report request error (attempt {attempt + 1}/{self.max_retries}): {e}")
except Exception as e:
logger.error(f"Stats report unexpected error: {e}")
break
# 重试延迟
if attempt < self.max_retries - 1:
await asyncio.sleep(self.retry_delay * (attempt + 1))
# 所有重试都失败,持久化到文件
self._persist_failed_events(events)
def _persist_failed_events(self, events: List[AICallEvent]):
"""持久化失败的事件到文件"""
try:
failed_file = Path(self.FAILED_EVENTS_FILE)
existing = []
if failed_file.exists():
try:
existing = json.loads(failed_file.read_text())
except (json.JSONDecodeError, IOError):
existing = []
# 添加新的失败事件
for event in events:
existing.append(event.to_dict())
# 限制最多保存1000条
if len(existing) > 1000:
existing = existing[-1000:]
failed_file.write_text(json.dumps(existing, ensure_ascii=False, indent=2))
logger.info(f"Persisted {len(events)} failed events to {self.FAILED_EVENTS_FILE}")
except Exception as e:
logger.error(f"Failed to persist events: {e}")
def _retry_failed_events(self):
"""重试之前失败的事件"""
try:
failed_file = Path(self.FAILED_EVENTS_FILE)
if not failed_file.exists():
return
events_data = json.loads(failed_file.read_text())
if not events_data:
return
logger.info(f"Retrying {len(events_data)} previously failed events")
# 尝试发送
payload = {"events": events_data}
url = f"{self.platform_url.rstrip('/')}/api/stats/report/batch"
headers = {"X-API-Key": self.api_key, "Content-Type": "application/json"}
try:
with httpx.Client(timeout=self.timeout) as client:
response = client.post(url, json=payload, headers=headers)
if response.status_code == 200:
# 成功后删除文件
failed_file.unlink()
logger.info(f"Successfully sent {len(events_data)} previously failed events")
except Exception as e:
logger.warning(f"Failed to retry events: {e}")
except Exception as e:
logger.error(f"Error loading failed events: {e}")
def __del__(self):
"""析构时发送剩余事件"""
if self._buffer:
self.flush()
try:
if self._buffer:
self.flush()
except Exception:
pass # 忽略析构时的错误