Compare commits

199 Commits

Author SHA1 Message Date
e938ecda80 feat: 支持工作流知识库多库检索 2026-09-04 17:40:55 +08:00
431fe8e707 perf: 减少工作流设计器重复计算与序列化
- 缓存节点汇聚模式拓扑分析结果

- 草稿保存复用内容序列化结果
2026-09-04 14:56:20 +08:00
0968e3bfa5 feat: 完善用户确认节点选项与输出流转
- 重构确认节点单选多选配置及输出参数契约

- 统一管理端、用户中心、匿名分享和公共接口恢复流程

- 增加保存校验、错误契约及交互测试
2026-09-04 14:55:55 +08:00
65c85180c2 feat: 支持知识库文档批量删除与分块内联编辑 2026-09-04 11:33:09 +08:00
0f126ad489 feat: 异步同步知识库分块检索索引 2026-09-04 11:32:52 +08:00
1e59063c37 fix: 优化自动导入异常状态展示 2026-09-04 11:29:14 +08:00
788c8e5459 fix: 恢复服务重启后的自动导入任务 2026-09-04 11:29:06 +08:00
c3673ece46 fix: 统一文档解析文件格式校验
- 统一知识库和工作流支持格式并增加前后端上传拦截

- 拒绝 XLS 与伪装 XLSX,避免空内容解析成功
2026-09-02 19:15:01 +08:00
36acf37976 fix: 补齐知识库工作流文档标题 2026-09-02 18:49:54 +08:00
1dd0ac167c fix: 修复接口说明弹窗小窗口展示 2026-09-02 16:47:09 +08:00
6498f1049a fix: 支持关闭自动导入状态提示 2026-09-02 16:47:02 +08:00
1f37b0a8ae perf: 优化SQL工作台大结果渲染
- 提供一百到一万行返回档位

- 使用虚拟表格降低大结果集页面开销
2026-09-01 17:02:58 +08:00
c00369f6b9 feat: 完善数据空间查询资源边界
- 将联邦源数与分片上限改为配置驱动

- 固化工作台一万行上限及结果字节保护
2026-09-01 17:02:37 +08:00
3e34b65bcb fix: 修复 Hash 路由跳转丢失部署基路径
- 修复懒加载分包失败后跳转到站点根路径的问题

- 统一管理端和用户中心新窗口路由解析

- 增加部署基路径与 Hash 路由回归测试
2026-08-31 18:47:54 +08:00
263f5f4b8b feat: 支持工作流对话匿名分享
- 增加免登录公共接口、访客隔离、限流和匿名上传校验

- 分离 SSE 连接与运行生命周期,支持刷新恢复服务端权威状态

- 持久化分享页对话并优化时间线滚动与输入区交互
2026-08-31 16:45:21 +08:00
6daf805cd0 feat: M28 增加工作流汇聚可视化配置 2026-08-31 15:55:05 +08:00
4386b2a1a6 feat: M28 增加工作流汇聚安全校验 2026-08-31 15:54:50 +08:00
f28e3919ac fix: 修复智能体聊天图片能力判断 2026-08-31 15:10:40 +08:00
0cedf85729 fix: 统一工作流空媒体参数处理 2026-08-31 15:00:49 +08:00
155af9989c feat: 优化定时任务管理与日志体验
- 增加工作流选项、时间范围筛选和日志详情展示

- 支持可配置自动刷新、轻量局部更新与响应式布局
2026-08-31 14:57:20 +08:00
8c174e5c02 feat: 切换定时任务至分布式调度底座
- 以执行账本和有界 Worker 承载重负载任务与故障接管

- 接入统一调度 Starter 并增加 MySQL 迁移、指标和回归测试
2026-08-31 14:57:08 +08:00
17ef189862 fix: 补全前端容器公共 API 代理
- 将 /flow/public-api/ 请求转发到后端 /public-api/

- 保持工作流等公共接口的同源访问路径
2026-08-31 13:46:54 +08:00
e6b2e2798f feat: 接入一库一工具知识检索
- 编译知识库英文运行名、描述、检索配置和独立 Registration

- 统一最终分数阈值并保持模型上下文、检索事件与引用一致

- 完善 AG-UI 知识库检索运行态与完成态展示
2026-08-29 15:42:21 +08:00
4823b0741f fix: 支持可信内部文件引用读取
- 通过服务端文件记录和存储配置恢复可信物理读取句柄

- 文档解析与工作流文档节点优先读取已确认的内部存储对象

- 补齐路径校验、记录异常和读取边界测试
2026-08-29 15:42:07 +08:00
1c68e3582c fix: 支持模型异常后继续文档对话
- 将本轮文档上下文纳入可持久化的 Agent 用户消息

=- 在异常提示中提供请重试动作并自动发送继续
2026-08-26 22:55:20 +08:00
619b60600d feat: 优化工作流运行输入交互
- 合并运行参数与问题输入并锁定首轮参数

- 优化文件上传、参数摘要与十二小时草稿恢复

- 补充输入表单国际化与相关测试
2026-08-26 22:48:42 +08:00
240b84063a fix: 修正工作流试运行参数名称展示
- 默认字段标题回退为实际配置的参数名称

- 保留显式展示标题并统一表单与引用口径
2026-08-26 19:16:50 +08:00
ec6e03587a fix: 修正自动导入完成提示
- 根据关联文档真实状态识别已完成的历史失败批次

- 完成后自动移除提示并低频复查失败或中断批次
2026-08-26 18:58:42 +08:00
1ccdafdb47 chore: 增加配置 2026-08-26 18:18:15 +08:00
e38821e48a feat: 完善数据中枢联邦查询闭环
- 重构数据源生命周期、元数据纳管与运行时切换

- 增加只读 SQL、查询审计、跨节点取消与工作流联动

- 完善管理端连接配置、元数据浏览与 SQL 工作台
2026-08-26 18:14:34 +08:00
3e79e99925 fix: 避免空引用集合触发无效查询
- Agent 与 Skill 引用查询在空 ID 集合时直接返回

- 补充不调用 listByIds 的回归测试
2026-08-26 18:12:56 +08:00
98b34bd4bb feat: 重构数据空间与 SQL 工作台
- 提供连接管理、逻辑表编排和轻量 SQL 工作台

- 增加 SQL 补全、执行分析、结果分栏与导出交互

- 统一数据空间导航、图标和编辑器体验
2026-08-25 01:06:53 +08:00
c27e97bcc2 feat: 新增统一数据空间与联邦查询能力
- 提供数据连接、元数据、逻辑表与关联编排能力

- 接入联邦查询、执行分析、统计估算和 SQL 补全接口

- 增加查询预算、凭据保护、租户隔离和 V61 初始化迁移
2026-08-25 01:04:45 +08:00
9068d42f4d chore: 进入 v1.2.0 版本开发 2026-08-20 11:41:18 +08:00
fd64073148 release: 发布v1.1.0 2026-08-20 11:34:00 +08:00
f63cd9be4d fix: 保留多次上下文整理记录
- 为同一轮多次整理分配独立状态键

- 防止后续整理覆盖已完成的历史记录
2026-08-20 11:24:55 +08:00
407b85c8a9 fix: 移除 Timeline 合成处理状态
- 仅依据真实思考、工具和状态事件展示处理过程

- 更新快速工具调用状态测试
2026-08-20 11:24:41 +08:00
9078ca163e fix: 保证工具调用开始态可见
- 工具开始事件后让出一次有界绘制机会

- 覆盖快速工具调用的状态可见性测试
2026-08-20 11:24:25 +08:00
7f083a9433 fix: 明确 Skill Git 访问策略注入构造器
- 标记生产构造器供 Spring 容器稳定选择

- 补充组件实例化回归测试
2026-08-20 11:24:09 +08:00
7d654c3302 feat: 优化 Agent Studio 试运行交互
- 将内置工具配置拆分为独立页签

- 支持试运行面板平滑居中展开和快捷收起

- 运行和审批期间禁用会话清理
2026-08-20 11:18:37 +08:00
fa07134cf8 feat: 支持 Agent 运行刷新恢复
- 解耦 Runtime 与浏览器 SSE 订阅并增加 Redis 游标日志

- 支持正式聊天与草稿试运行刷新重连和权威终态恢复

- 完善显式取消、owner 丢失、容量限制与故障测试
2026-08-20 11:18:10 +08:00
310fc1fb58 fix: 修复 Jcseg 可执行包资源加载
- 将 jcseg-core 标记为 Spring Boot 启动时解包依赖
2026-08-19 22:43:53 +08:00
c96f772f01 fix: 隔离 LibreOffice 并发转换配置
- 为每次 soffice 调用创建独立临时用户配置目录

- 转换结束后自动清理并保持无头安全启动参数
2026-08-19 22:42:25 +08:00
30b2cc36fd fix: 保留 Agent 手动压缩阈值
- 保存和试用时不再覆盖已配置的 Token 压缩门槛
2026-08-19 22:41:17 +08:00
7a5298c3fc feat: 完善 Skill 发布操作体验
- 在列表提供发布、重新发布和下架入口

- 发布前执行校验并按需展开校验问题
2026-08-19 22:40:41 +08:00
9579619384 feat: 支持 Skill 仓库扫描与批量导入
- 统一支持本地单包、多包和 Git 候选最多 50 项导入

- 固定提交并限制 SSRF、DNS、并发、频率、超时和仓库资源

- 增加导入对话框、接口契约和安全回归测试
2026-08-19 22:39:55 +08:00
4e8640dcaf feat: 完善 Agent 标准交互与安全运行时
- 接入 AG-UI 运行投影、Turn 时间线和审批隔离

- 增加 Agent Skill 冻结绑定与运行时消费闭环

- 增加受控工作区、内置工具和私有 Artifact 生命周期
2026-08-19 22:13:41 +08:00
91d66e636d feat: 增加 Agent Skill 工具绑定数据结构
- 新增 Agent 与 Skill 草稿绑定及发布快照字段

- 新增 Skill 工具绑定、冻结摘要与运行配置字段
2026-08-19 21:54:24 +08:00
5de3b209c1 feat: 重塑技能库与统一文件工作台
- 统一分类检索、新建导入和发布交互

- 使用单一文件工作台编辑全部 Skill 资源

- 下沉可复用 Markdown 与代码编辑能力
2026-08-14 18:58:20 +08:00
77d66e1b42 feat: 重构标准 Skill 管理与发布链路
- 统一标准 ZIP 导入导出与通用资源模型

- 收口分类范围权限和创建人查询

- 完善发布快照、审批幂等与数据库清理迁移
2026-08-14 18:53:26 +08:00
9be9bd7665 fix: 优化顶级部门存在和子部门重复时,用户导入错误的问题 2026-08-13 22:30:45 +08:00
64a85c6a5b feat: 统一列表模糊搜索行为
- 统一管理端和用户中心搜索参数及多字段包含匹配

- 修复聊天搜索竞态并优化部门重名路径展示

- 补充部门展开、模型空白词和聊天查询回归测试
2026-08-13 22:29:59 +08:00
765006747a feat: 优化内容模板变量引用与换行展示
- 内容模板直接引用上游参数并自动维护运行参数

- 支持 Enjoy 变量标签编辑及中文变量显示

- 保留试运行与聊天运行页的模板输出换行
2026-08-11 21:41:58 +08:00
fe41d62b8a fix: 让 LLM 节点应用模型消息格式配置
- 从模型高级配置解析内容块数组模式并兼容旧配置项

- 向 OpenAI 兼容、DeepSeek 与 Ollama 聊天配置透传消息格式

- 补充配置优先级、回退和请求序列化测试
2026-08-11 20:56:09 +08:00
ac9e200a15 fix: 强化文档分块与索引失败处理
- 对最终向量分块应用 BGE-M3 上下文硬限制

- 批量写入和删除关键词索引并校验执行结果

- 外部索引回滚失败时保留明确错误提示

- 补充自动重分块、快照清理和失败回滚测试
2026-08-10 23:58:24 +08:00
6c491bd893 feat: 完善文档分块参数配置
- 支持自定义正则匹配内容保留开关及配置持久化

- 约束分块重叠长度始终小于分块长度

- 补充中英文交互文案
2026-08-10 23:57:56 +08:00
742a4b1647 perf: 优化文档列表与分块索引查询
- 列表页仅查询展示字段并复用文档分块统计

- 移除文档分页对分块表的关联聚合

- 增加文档分块 document_id 索引和 SQL 回归测试
2026-08-10 23:56:21 +08:00
402c0f16b8 fix: 支持解析工作流受管上传文件
- 优先通过上传记录验证后的读取器加载文件内容

- 保留普通存储与远端地址校验回退路径

- 补充来源读取和解析桥接回归测试
2026-08-10 23:54:25 +08:00
1c0fbfa5ff fix: 修复工作流入口标题与提示词校验
- 支持系统入口标题清空占位与下游引用展示同步

- 降低字段编辑触发的节点图重算和无效渲染

- 在保存及执行前拦截空白的大模型用户提示词
2026-08-10 23:15:41 +08:00
6bfd440214 feat: 统一恢复管理端列表上下文
- 统一列表路由状态、分页初始化与安全返回契约

- 接入知识库、工作流、插件、审批、Skill、Agent、Bot、反馈和定时任务链路

- 补齐公共能力与关键返回路径自动化测试
2026-08-10 16:12:43 +08:00
9d2fa39a2d fix: 完善工作流公共接口上传与错误契约
- 区分 HTTP 请求标识与内部上传标识,补齐 Redis 旧记录兼容和关联日志

- 保持归一化 MIME 一致,并隔离工作流鉴权错误契约对其他公共接口的影响

- 收口 Multipart 操作日志与对象存储故障分类,归档范围:S05
2026-08-10 11:41:09 +08:00
bae7b18977 fix: 完善自动导入异常中断与恢复
- 自动导入基础设施异常触发批次熔断,保留完整日志并输出安全错误信息

- 增加恢复令牌与租约围栏、无文档失败项重建及消息退避机制

- 前端展示中断状态并在状态请求失败后自动恢复轮询

- 补充批次中断迁移、配置与并发异常路径测试
2026-08-10 11:26:00 +08:00
54d85ae460 feat: 完善工作流 Public API 调用能力
- 支持 JSON 文件 URL 简写与 Multipart 单请求文件上传

- 完善执行拓扑、枚举状态、节点名称、恢复校验和安全错误响应

- 增加临时上传生命周期清理并升级 MinIO SDK

- 重构工作流接口调用说明弹窗的扁平响应式布局
2026-08-09 21:27:30 +08:00
0d14f1c165 feat: 支持知识库 CSV 大文件导入
- 增加 CSV 流式解析、表格语义分块和分页预览

- 增加快照两阶段清理、失败重试和格式校验

- 补充批量入口、管理端交互和回归测试
2026-08-07 13:11:59 +08:00
13dec6c216 feat: 为访问令牌增加名称
- 支持创建和编辑访问令牌名称

- 在列表首列展示名称并兼容历史数据
2026-08-07 12:55:04 +08:00
d244a0404d fix: 收口管理端页面权限与工作流运行授权
- 页面选项接口改用所属页面权限并返回最小数据视图

- 统一校验工作流引用、租户、状态与定时任务执行主体

- 补充聊天记录权限迁移和权限隔离回归测试
2026-08-07 12:51:21 +08:00
6ad004da9b feat: 在用户列表展示角色信息
- 分页批量补全用户关联角色名称,避免逐行查询

- 支持多角色标签、溢出数量和完整信息提示
2026-08-07 12:47:04 +08:00
4b52d85512 fix: 统一资源卡片底部操作布局
- 将可见操作紧凑靠左并固定更多菜单位置

- 统一工作流、知识库、智能体和插件卡片密度

- 补充无占位布局回归测试
2026-08-07 12:41:50 +08:00
7a64cfcaa4 fix: 修复知识库失败批次继续与重分块
- 统一继续和重试逻辑,恢复全部失败明细

- 向量化失败时废弃旧快照并重新分块

- 增加 BGE-M3 分块硬上限与索引写入结果校验
2026-08-07 12:39:27 +08:00
7082041e6e fix: 修复 AUTO 策略空分块兜底
- AUTO 推荐无分块时回退自然段长度拆分

- 保留默认或页面传入的分段长度与重叠参数

- 补充默认、自定义与显式策略回归测试
2026-08-07 12:21:02 +08:00
170b315459 feat: 扩展用户管理统一搜索范围
- 支持按账号、昵称、电话、邮件和角色名搜索

- 补充搜索提示、特殊字符处理及前后端测试
2026-08-07 12:14:55 +08:00
6544cdcff0 feat: 支持用户导入按部门层级路径匹配
- 按独立顶级分支构建路径并拒绝重名与异常层级

- 兼容旧版部门名称表头并更新导入模板

- 补充多根路径与模板歧义场景测试
2026-08-03 17:43:47 +08:00
7bcaf69fc0 fix: 修复代码节点补全与参数同步边界
- 保持 main 参数运行协议并在失焦或销毁前刷新输出分析

- 按输入名称生成 JavaScript 和 Python 补全且隐藏旧版 _result

- 补充复杂签名与命名参数补全测试
2026-08-03 17:27:00 +08:00
a75ae9a843 feat: 默认省略代码节点显式 main 调用
- 新节点仅生成 main 与 return 并同步输入形参

- Python 运行时按配置顺序传入独立参数值

- 保留历史显式调用和单对象参数写法
2026-08-03 16:58:20 +08:00
46398d1365 feat: 补全代码节点输入输出智能同步
- 为 JavaScript 和 Python 新节点生成 main 与显式调用脚手架

- 同步输入签名、静态返回字段和输出参数并推断受支持类型

- 增加安全改写边界与 Tinyflow 定向测试
2026-08-03 16:20:54 +08:00
08fe3ba1f1 feat: 增加多端登录可配置 2026-08-03 15:27:47 +08:00
3a77149087 chore: 统一工作流列表代码格式 2026-08-03 14:54:43 +08:00
2a930a1c97 fix: 补充智能体图片能力类型 2026-08-03 14:54:16 +08:00
c757eeed50 fix: 保持开始节点字段配置一致
- 同步字段重命名后的默认标签

- 保留图片等资源参数的内容类型
2026-08-03 14:54:02 +08:00
2eaf2eace7 fix: 校验数据中心 Excel 上传文件
- 拒绝非 xls/xlsx 文件并清理待上传状态

- 兼容数据中心字符串与数字标识
2026-08-03 14:53:39 +08:00
0860492446 fix: 固定公共工作流接口响应类型
- 统一声明 application/json 响应并补充契约测试
2026-08-03 14:51:34 +08:00
de0ccf03aa test: 更新工作流聊天页返回路径断言 2026-08-03 14:51:02 +08:00
89824b5b9b fix: 过滤工作流输出中的思考内容
- 统一清洗运行结果、详情、预览与复制文本

- 支持嵌套、混合及未闭合思考标签
2026-08-03 14:50:52 +08:00
8c334be65d fix: 防止定时任务引用失效工作流
- 保存任务时校验工作流权限与必填参数

- 删除工作流前检查并重新确认定时任务引用
2026-08-03 14:50:28 +08:00
93db17b384 fix: 收紧智能体聊天历史访问范围
- 普通账号仅可访问本人 Agent 会话

- 超级管理员保留全量查询并限制筛选入口
2026-08-03 14:49:31 +08:00
866688b92f fix: 统一账号角色校验与默认首页
- 创建与导入账号时强制校验启用角色

- 按启用角色返回默认首页并过滤禁用角色
2026-08-03 14:48:39 +08:00
219e4f7eff feat: 优化数据中枢 Excel 连接创建交互
- 在新增连接中完成 Excel 上传并自动填写连接名称

- 限制上传格式并提供空表和无表头错误反馈

- 移除旧导入入口和无效测试连接操作
2026-08-03 11:39:40 +08:00
19dac5146c fix: 完善数据中枢导入与查询链路
- 支持常见 Excel 表头、工作簿校验及数据可靠落库

- 修复大整数 ID 精度和逻辑表 SQL 解析问题

- 为查询数据节点注入结构化上下文并兼容 SQL 代码块
2026-08-03 11:39:14 +08:00
e3228837f4 feat: 统一聊天助手头像
- 工作流运行状态、智能体正式聊天与试运行展示助手头像

- 新增默认机器人头像及消息、空状态回退组件
2026-08-03 11:24:12 +08:00
dc99d1aa52 fix: 展示 JavaScript 代码节点执行错误
- 配置脚本执行超时并保留可定位的 JavaScript 异常信息

- 在试运行步骤和终态结果中展示并展开失败节点
2026-08-03 11:17:44 +08:00
1bf755f6c4 feat: 支持 Python 代码节点 main 返回输出
- 自动传入节点输入并映射 main 返回字典,兼容历史 _result

- 限制脚本输出缓冲并补充运行时与编辑器测试

- 同步 Python 帮助、占位提示和独立可执行补全片段
2026-08-03 11:16:03 +08:00
51dbfd41b6 feat: 完善知识库批量导入与公共 API
- 新增批量异步导入、状态查询、失败重试与中断恢复链路

- 拆分知识库读取、导入、维护权限并完善 Public API 契约

- 补充数据库迁移、管理端交互、接口说明与相关测试
2026-08-03 11:14:41 +08:00
6df3dd9981 feat: 支持智能体可见范围管理
- 未分类智能体按可见范围绕过分类白名单

- 提供个人、部门、公开范围配置及列表状态标签
2026-07-31 16:45:41 +08:00
527336bfc9 fix: 保留新建用户选择的部门
- 创建账号时保留表单提交的部门归属

- 补充跨部门创建回归测试
2026-07-31 16:44:02 +08:00
12080ceedb perf: 优化旧版浏览器页面恢复与资源加载
- 页面恢复时延后版本检查并补齐根背景,减少白闪与首帧卡顿

- 面向 Chrome 90 收敛公共分包,按需加载资源弹窗和知识库面板

- 压缩工作流图标并补充延迟加载与可见性生命周期测试
2026-07-31 16:11:14 +08:00
5c29ca9407 fix: 将工作流文档解析上限统一为 100MiB
- 前后端统一单文件与总大小限制为 100MiB

- 按实际读取字节数拦截超限文档并记录大小差异

- 补充存储、远程流与边界场景测试
2026-07-31 14:52:06 +08:00
4a0efe8879 fix: 完善工作流图片参数流转
- 图片参数使用 Object 类型并过滤上游图片引用

- 试运行支持上传、URL 和素材库图片输入

- 将模型图片能力映射到运行配置
2026-07-31 14:51:07 +08:00
fb08424cef feat: 完善循环输出扁平聚合
- 校验并恢复循环输出的聚合策略

- 将最终类型并入参数值并优化同排开关交互
2026-07-31 14:46:46 +08:00
0754ad0792 feat: 收口工作流对话最终结果展示
- 主时间线只展示顶级工作流最终输出和有限运行进度

- 补齐结构化结果、人工审核交互与实时详情回归

- 优化数组层级和复制操作
2026-07-31 14:41:43 +08:00
f92707dac8 feat: 优化知识库分块操作区与滚动导航
- 将向量化操作上移到分块预览前的统一操作区

- 增加页顶页末双气泡并完善边界禁用状态
2026-07-31 14:38:05 +08:00
a051dbacba feat: 优化知识库与工作流卡片布局
- 统一紧凑卡片、完整标题与状态元信息展示

- 移除多余入口指示并将工作流分享操作前置
2026-07-31 14:34:43 +08:00
f872eac1f9 fix: 完成系统向智能体数据链路切换
- 切换工作台、聊天历史、资源候选与公共调用到 Agent

- 加固资源绑定、删除保护及发布运行并发控制

- 隔离旧 Bot 专属服务和组件并保留兼容入口
2026-07-31 14:24:15 +08:00
f0aba1eddd feat: 增加条件节点正则匹配
- 使用 RE2/J 完成安全正则执行和分层校验

- 增加全宽多行输入、说明提示和专项测试
2026-07-31 14:23:47 +08:00
41b056b7e3 fix: 修复工作流公共 API 调用问题
- 限制远程文档仅访问公网地址并校验重定向目标

- 统一访问令牌 401/403 与过期执行状态 404 语义

- 校正节点查询参数和工作流状态文档
2026-07-31 11:27:37 +08:00
1cbee6b018 fix: 修复插件分类查询并保留返回状态
- 统一按插件名称进行模糊查询并支持分类筛选

- 恢复插件列表与工具编辑的页码、分类和搜索条件
2026-07-31 09:54:45 +08:00
c2ed5a24a3 fix: 保留工作流列表返回状态
- 记录页码、分类和搜索条件并同步到路由

- 从工作流设计页返回时恢复原列表位置
2026-07-31 09:51:21 +08:00
615092f4f7 feat(XL13): 归档工作流对话运行界面
- 接入发布快照优先与未发布草稿受控运行

- 支持文本和思考流式输出、循环多输出及实时运行详情

- 完成聊天分享、图片输入、中止与清空重来

- 补充后端与前端定向回归测试
2026-07-31 09:40:54 +08:00
048aa9bc1e feat: 增加审批步骤发起人部门限制
- 增加配置期与提审期合法审批账号校验

- 冻结发起人部门并统一待办与办理权限判断

- 增加兼容迁移、前端联动和自动化测试
2026-07-31 09:38:53 +08:00
0b764b79de feat: 优化聊天附件拖拽上传提示
- 不支持格式改用轻量提示并展示支持范围

- 扩展拖拽区域并增加全区上传覆盖层

- 补充文档和图片格式校验测试
2026-07-31 09:19:11 +08:00
2267517fd8 fix: 保持工作流分类筛选与列表一致
- 合并分类与搜索条件,避免筛选状态相互覆盖

- 保存工作流后按当前条件刷新并同步左侧选中状态
2026-07-30 21:07:02 +08:00
100d744c25 feat: 将工作流与 FAQ 上传上限扩至 20MB
- 同步管理端、用户中心与后端工作流文件校验

- 扩展 FAQ 普通及分享上传限制并补充边界测试
2026-07-30 17:39:13 +08:00
2abff304ed feat: 优化代码节点 main 编写体验
- 更新代码节点帮助、占位示例与补全片段

- 保留 _result 旧版兼容提示并补充补全测试
2026-07-30 17:34:56 +08:00
9959f95b94 fix: 防止开始节点重名参数被删除
- 校验开始节点参数名并保留冲突输入

- 在字段归一化前拦截重名并补充回归测试
2026-07-30 16:23:34 +08:00
5dbb46ef8f fix: 调整知识库与工作流卡片信息布局
- 将发布状态移动到可见范围下方,释放标题空间

- 修正知识库卡片标题左对齐
2026-07-30 16:20:39 +08:00
57bd7b5d06 feat: 支持审批步骤绑定部门与多对象
- 新增审批对象关联表和存量数据迁移

- 支持用户、角色、部门多选及办理权限匹配

- 完善部门状态与批量管理交互
2026-07-30 15:13:11 +08:00
864cea6135 fix: 兼容客户端文档 MIME 差异
- 接受 WPS及系统 MIME 别名并统一保存服务端标准类型

- 保留扩展名、大小和文件头校验以拒绝伪装文件
2026-07-30 15:06:08 +08:00
ff5f90121b fix: 使用发布快照校验图片输入能力
- 正式聊天和模型编译统一读取发布快照中的图片能力

- 草稿态保留实时模型能力并为旧快照提供安全关闭策略
2026-07-30 15:05:42 +08:00
1b40829135 fix: 修复强制重置密码流程与表单状态
- 修正强制改密跳转及免旧密码的服务端校验

- 统一双端表单错误布局与更新按钮加载状态

- 补充认证服务和强制改密路由测试
2026-07-30 15:03:53 +08:00
03f45212ef perf: 优化日志保留与分页查询
- 降低普通只读请求的日志写入并保留敏感 GET 审计

- 增加数据库分批清理、时间索引和文件滚动容量上限

- 增加近 30 天筛选、稳定倒序和分页大小限制
2026-07-30 14:21:36 +08:00
c78074a969 feat: 按模型上下文自动配置智能体压缩阈值
- 从 llm.json 解析上下文与输出上限,并增强短模型 ID 匹配

- 切换模型、保存和试运行时按预算公式重算,目录缺失时回退 30K

- 修复模型列表接口未返回能力元数据
2026-07-30 14:20:10 +08:00
5f11219226 feat: 支持展示并复制重置后的密码
- 账号重置接口返回本次使用的默认强密码

- 管理端新增紧凑结果弹窗与复制反馈

- 补充服务测试和中英文文案
2026-07-30 14:17:44 +08:00
766554bf63 feat: 完善循环节点配置与作用域输出
- 支持次数与数组独立或组合配置并补齐检查规则

- 统一循环体临时变量与下游正式输出候选

- 稳定知识库对象数组字段并补充前后端测试
2026-07-29 18:10:23 +08:00
19c7b60a65 fix: 修复 Chrome 90 智能体文档上传失败
- 复用兼容 UUID 生成逻辑,移除对 crypto.randomUUID 的依赖

- 补充旧浏览器缺失 randomUUID 时的上传回归测试
2026-07-29 16:20:37 +08:00
9c3badc55c fix: 按下游查询节点控制 LLM 配置显示
- 仅展示可绑定的下游数据查询节点,无候选时隐藏查询数据信息

- 移除模型设置下的冗余模型标签
2026-07-29 16:18:58 +08:00
d45c67a317 feat: 支持智能体文档附件与轻量读取
- 建立文档上传、异步读取、对象存储、补偿与聊天绑定闭环

- 按智能体 20K 上下文预算选择文档片段并保留稳定引用

- 统一聊天文件卡片、类型图标、草稿恢复与可靠下载
2026-07-29 01:32:19 +08:00
fceedd02cd feat: 扩展知识库文档上传限制至 100 MB
- 上传前校验单文件大小并给出本地化提示

- 同步支持格式与中英文说明
2026-07-29 00:57:04 +08:00
1ae8a22afe perf: 收敛工作流状态与高 IO 节点开销
- 落地 Redis 版本状态、触发租约和定义缓存

- 优化数据批写、插件请求、文件下载与审计日志

- 补齐循环范围校验、轮询兼容和专项测试
2026-07-29 00:47:48 +08:00
5ee6065017 fix: 修复流式输出展开状态被覆盖
- 保留智能体思考内容的手动展开与关闭选择

- 工作流试运行默认跟随当前节点,并允许用户自由多开或关闭
2026-07-29 00:45:46 +08:00
f392c896f9 fix: 扩展模型消息内容块数组配置
- 升级高级设置为消息级格式并兼容旧 system 配置

- 增加 VLM 图片首轮与文本追问的多轮连接验证
2026-07-28 12:24:42 +08:00
dc35ddc3e4 fix: 统一详情页返回上级列表
- 修正管理端插件、工作流与系统详情页返回目标

- 修正用户中心执行记录与助手详情返回目标

- 保留审批页签及关联资源筛选上下文
2026-07-28 12:23:36 +08:00
1404c2ddc5 perf: 优化工作流与智能体编排界面性能
- 使用登录态独立全屏壳层承载编排页面

- 保留鉴权、锁屏、水印及原有设计器功能

- 增加独立路由回归测试
2026-07-28 12:23:20 +08:00
1630d6194a fix: 修复推理模型连接验证截断
- 响应达到输出上限时使用 512 Token 单次重试

- 为自部署 vLLM/SGLang 传递关闭思考参数

- 补充截断重试与请求参数回归测试
2026-07-28 12:20:12 +08:00
a22ca24906 chore: 调整本地启动与模型调试配置
- 关闭 Spring Boot Maven 插件强制 fork

- 增加 AgentScope OpenAI 客户端调试日志
2026-07-27 19:42:18 +08:00
24b1142738 perf: 收敛前端按需加载与图标导入
- 延迟加载第三方登录组件并使用按需 SVG 图标入口

- 修复 CardList 图标依赖并整理组件格式
2026-07-27 19:42:03 +08:00
908eb5583d fix: 清理前端异步请求生命周期
- 页面卸载和弹窗关闭时终止 SSE

- 为版本检查增加超时、中止与重复请求保护
2026-07-27 19:41:48 +08:00
a904f63ec8 feat: 优化聊天历史筛选布局 2026-07-27 19:41:34 +08:00
b0ed512e97 fix: 收敛 Agent 设计器导航与记忆配置
- 增加返回智能体列表入口

- 移除旧消息数压缩参数并保持令牌阈值配置
2026-07-27 19:41:24 +08:00
b08eb009bb perf: 优化 Agent 流式会话处理
- 合并流式通知与持久化写入,限制浏览器会话缓存

- 增量投影轮次事件并稳定关联异步工具任务
2026-07-27 19:41:11 +08:00
5497931abd fix: 完善操作日志与 Skill 审计
- 记录操作成功失败状态并保留原始业务异常

- Skill 写请求仅审计资源标识,避免正文和配置泄露
2026-07-27 19:40:53 +08:00
2892a7eddc fix: 强化工作流与插件运行状态处理
- 显式传播工作流缓存故障并补充仓储测试

- 防止状态轮询重入和过期响应,规范执行记录无权限响应
2026-07-27 19:40:40 +08:00
567fd12706 feat: 完善模型能力识别与验证
- 自动识别模型类型、视觉、推理和工具能力并保留手动覆盖

- 使用 AgentScope 工具与视觉探测并统一管理端配置反馈
2026-07-27 19:40:23 +08:00
0dc5c3ca55 feat: 重构 Skill 管理与编辑工作台
- 统一列表、分类、新建与详情页的产品交互

- 提供 Markdown 实时编辑、源码与脚本资源工作台

- 实现能力自动保存、导入导出及完整状态反馈
2026-07-27 18:54:57 +08:00
2a9e882ac6 feat: 完善 Skill 管理与发布治理
- 实现标准资源存储、能力绑定及双格式导入导出

- 接入分类、可见范围、审批发布与资源权限校验

- 补充并发、租户隔离、安全边界和迁移契约测试
2026-07-27 18:54:20 +08:00
aedefe6b5e perf: 优化智能体与工作流幕布渲染性能
- 分阶段加载智能体配置并按需缓存 MCP 工具

- 合并画布状态更新与节点尺寸监听,启用大图可视区域渲染和静态连线

- 隔离 Tinyflow Store 实例并补充数据同步与回归测试
2026-07-27 18:27:01 +08:00
dc7e46260b feat: 增加 system 消息格式配置
- 模型管理支持选择字符串或内容块数组格式

- 验证连接携带 system 消息并复用正式运行时模型配置

- 补充配置映射与验证消息角色测试
2026-07-27 15:47:58 +08:00
ba4253e13e chore: 清理测试文件 2026-07-24 19:05:39 +08:00
ac07899dc4 fix: 稳定管理端启动页字体显示
- 启动文案固定使用 700 字重

- 复用应用字体栈避免加载前后视觉跳变
2026-07-24 19:03:27 +08:00
cb06599dca test: 增加管理端部署契约验证
- 增加生产构建与 Nginx 镜像冒烟命令

- 校验根路径跳转、静态资源前缀和错误分享地址
2026-07-24 19:03:08 +08:00
da59c713f9 fix: 提升管理端启动与导航稳定性
- 等待初始路由就绪并提供可恢复的启动失败页面

- 收敛失效登录导航并按需加载 SVG 图标资源
2026-07-24 19:02:28 +08:00
a75f7baf1b fix: 统一管理端开发与部署路由契约
- 开发与生产统一使用 /flow/ 基路径和 Hash 路由

- 自动规范化本地根路径入口并补充契约测试
2026-07-24 19:00:47 +08:00
63eb55e24c feat: 将工作流分享改为独立页面
- 新增无后台布局的工作流分享与失效路由

- 同步分享地址并保留旧链接兼容

- 补充前后端分享链路测试
2026-07-24 18:51:53 +08:00
526e16163b fix: 修复分享链接路由与登录回跳
- 统一生成包含部署基路径和 Hash 路由的知识库、工作流分享地址

- 未登录访问分享页时保留目标地址,并在登录成功后自动回跳

- 限定分享密钥作用域并补充失效、加载失败及回归测试
2026-07-24 16:00:57 +08:00
41545fcef0 fix: 使用终态答案校正流式输出
- DONE 事件携带后端汇总的完整最终正文

- 前端完成时替换可能受损的流式文本

- 补充文件 URL 终态校正回归测试
2026-07-23 19:49:45 +08:00
417e846cbd fix: 仅按 Token 阈值压缩上下文
- EasyFlow 调用方忽略新旧消息数压缩配置

- 保留最小 Token 阈值与最近消息保留数量

- 隐藏智能体配置页的消息数阈值
2026-07-23 19:49:25 +08:00
8b4ba65e1c fix: 归并异步工具状态卡片
- 统一异步阶段事件的稳定任务标识

- 保留原始工具调用 ID 供事件关联

- 将提交、轮询和结果状态更新到同一审批卡片
2026-07-23 19:48:44 +08:00
4a8e633083 fix: 同步工具审批过期状态
- 以条件更新避免过期任务覆盖并发人工审批

- 将过期结果同步到本地或远程运行节点

- 补充跨节点命令与过期任务回归测试
2026-07-23 19:47:23 +08:00
df9fe4c2fe fix: 修复页签路由切换页面未更新
- 拆分页签分组与页面渲染身份,确保列表和详情正确切换

- 让禁用缓存的路由绕过 KeepAlive,并补充资源切换回归测试
2026-07-23 18:36:12 +08:00
3e8ee66ab6 feat: 支持发起人撤回审批
- 在我发起和审批详情中提供申请人撤回操作并恢复资源状态

- 补齐审批说明列表展示、动作权限校验和防重复提交

- 为审批决策增加行锁并补充撤回权限回归测试
2026-07-23 16:28:45 +08:00
ebfd91eeab fix: 统一知识库卡片布局
- FAQ 与文档知识库统一使用标题下方类型标签

- 同排卡片底部操作栏保持贴底对齐
2026-07-23 16:10:09 +08:00
5a42826d44 feat: 完成分享、单会话与发布审批改造
- 增加工作流协作分享与知识库卡片分享入口,统一低版本浏览器复制反馈

- Web 新登录替换旧会话,并保持 API Key 会话隔离

- 发布审批增加必填说明并在审批详情展示

- 账号重置与导入改用可配置默认强密码
2026-07-23 16:09:31 +08:00
caa1f07b66 fix: 修复审批详情路由缓存卡住
- 关闭审批管理菜单的不安全页面缓存

- 增加审批隐藏详情路由的缓存回归测试
2026-07-23 14:58:23 +08:00
19059fde96 perf: 优化管理端生产加载体验
- 图标与重型组件按需加载,优化菜单预取和页面请求链路

- 开启 gzip 与静态缓存并修复子路由 KeepAlive 冲突
2026-07-22 20:35:55 +08:00
9f06c238d3 fix: 兼容低版本浏览器消息复制
- 为 Chrome 90 增加剪贴板兼容回退

- 统一聊天与智能体试运行的原色对勾反馈
2026-07-22 20:07:34 +08:00
5a3d4788da fix: 优化页面恢复渲染并统一导航壳配色
- 延迟页面恢复后的版本检查,并移除常驻大面积背景模糊

- 统一侧栏、顶部工具栏和标签栏的导航壳背景色
2026-07-22 19:47:25 +08:00
53fb63802b feat: 增加服务商远端模型发现与一键添加
- 支持 OpenAI 兼容、Ollama 和阿里百炼模型目录适配

- 使用静态模型库识别能力并过滤未接入的生成模型

- 增加扁平模型列表、搜索筛选和幂等添加
2026-07-21 19:01:33 +08:00
9436cc5397 feat: 切换管理端聊天记录至智能体会话
- 管理端固定筛选 Agent 会话并拒绝旧 Bot 历史访问

- 筛选候选及页面语义切换为智能体并补充前后端回归测试
2026-07-21 18:55:11 +08:00
f658f120e6 fix: 修复工作流开始表单文案清空回退
- 保留标题和说明的显式空值

- 同步运行页与后端解析逻辑
2026-07-21 18:53:28 +08:00
4c28eaf393 fix: 修复菜单图标选择器公网依赖和空白显示
- 使用本地 Iconify 图标数据并补齐菜单常用图标

- 修复未知图标降级和弹出层触发器结构
2026-07-21 14:40:47 +08:00
791649c7d5 feat: 增强智能体模型调用兼容能力
- 增加模型流式开关和 HTTP 传输策略配置

- 使用 AgentScope 执行基础连接、流式与 VLM 双阶段验证

- 固定多模态校验图片并统一验证状态展示
2026-07-17 19:57:06 +08:00
ba21f861f4 fix: 统一 Web 异常的 HTTP 错误语义
- 支持业务异常声明 HTTP 状态、业务错误码与原始根因

- 规范参数错误、框架异常和未知异常的安全响应
2026-07-17 19:54:27 +08:00
1e6158be77 feat: 完善智能体图片聊天与会话恢复
- 增加私有图片上传、绑定、历史回显与生命周期清理

- 支持输入草稿恢复、图片交互和模型图片能力约束

- 修复旧脏会话幂等删除与前端会话恢复
2026-07-17 19:54:26 +08:00
62d763199f feat: 增加工作流编排临时草稿缓存
- 按当前标签页缓存并静默恢复未保存的工作流编排

- 优化试运行抽屉层级、窄屏宽度与长结果滚动

- 覆盖版本变化、缓存异常与保存竞态场景
2026-07-17 18:58:47 +08:00
5a91323a2e fix: 兼容旧模型内联思考标签
- 在结构化 reasoning 为空时解析正文开头的 think/thinking 标签

- 将思考与正文映射为现有流式事件并覆盖 Agent 与 Bot 链路

- 补充跨分片和旁路条件测试
2026-07-17 17:50:56 +08:00
27e50a7624 fix: 兼容 Chrome 90 前端运行环境
- 为管理端与用户中心补齐旧浏览器运行时 API 和回归测试

- 增加现代 CSS 与选择器降级,保持现有界面效果

- 固定 Chrome 90 构建目标并补充兼容依赖
2026-07-16 14:50:06 +08:00
705e0faab6 feat: 增加智能体对话体验配置
- 支持欢迎语、猜你想问和输入提示的编辑、草稿预览与发布态展示

- 补充配置校验、发布快照持久化和发布后回显修复
2026-07-14 21:21:57 +08:00
ce8b4fb420 Merge branch 'hotfix/encrypt_compatibility' into develop 2026-06-24 17:29:25 +08:00
1a91e16ac9 fix: 修复 ip 地址登录系统报错,ts 方法兼容旧版本浏览器 2026-06-24 16:58:49 +08:00
b1706e9589 Merge branch 'feat/encrypt_login' into develop 2026-06-23 16:02:39 +08:00
7cf6c31da3 Merge pull request 'feat/encrypt_login' (#3) from feat/encrypt_login into main
Reviewed-on: #3
2026-06-23 16:01:56 +08:00
e56f043483 feat: 登录信息加密传输 2026-06-23 16:00:47 +08:00
03ad011f64 fix: 修复工作流节点交互与旧浏览器兼容
- 修复 Tinyflow Vue wrapper 可选 boolean 默认关闭节点拖拽和连线的问题

- 为 Tinyflow 增加 structuredClone 兼容与 Chrome 90 构建目标

- 增加节点交互归一化与 Tab 空值防御
2026-06-23 15:59:03 +08:00
9c1de1657d Merge branch 'hotfix/frontend_error' into develop 2026-06-22 18:22:05 +08:00
999a21e2d9 Merge pull request 'fix: 登录表单兼容旧版本 chrome' (#2) from hotfix/frontend_error into main
Reviewed-on: #2
2026-06-22 18:18:41 +08:00
28797a83cc fix: 登录表单兼容旧版本 chrome
- 移除百度统计脚本
2026-06-22 18:18:02 +08:00
72c0115b19 fix: 合并 FAQ 向量化异常修复
- 合入 FAQ 向量化异常日志与业务错误提示
2026-06-14 16:22:33 +08:00
7591eb8cda Merge pull request 'fix: 修复增加 FAQ 报错的 bug' (#1) from hotfix/embedding_error into main
Reviewed-on: #1
2026-06-14 16:18:46 +08:00
ef4528a441 fix: 修复增加 FAQ 报错的 bug 2026-06-14 16:09:07 +08:00
e9ddeae33f chore: 调整开发分支为试验性版本 2026-06-08 17:22:48 +08:00
950148b3f7 feat: 增加技能管理模块试验性功能,等待优化 2026-06-08 16:52:41 +08:00
cb379e071c chore: 发布 easyflow 1.0.0
- 将 Maven revision 升级为 1.0.0

- 同步 easy-agents 依赖版本为 1.0.0
2026-06-05 14:06:17 +08:00
8b80770960 feat: 增加代码混淆支持 2026-06-05 13:53:26 +08:00
c316eff5be feat: 归档 XL10 异步工具业务编译层
- 将 AgentDefinitionCompiler 升级为 AgentRuntimeCompiler

- 接入 Workflow 和 Plugin 的同步/异步工具编译与 Redis 任务态

- 增加异步执行配置开关、聊天时间线聚合和后端测试
2026-06-04 15:23:56 +08:00
1ea863cb2c chore: 调整 Dockerfile 构造 2026-05-31 20:11:38 +08:00
0f4d10c43c feat: 增强多实例分布式部署兼容
- 增加定时任务分布式锁并覆盖 chatlog、文档导入和 Agent HITL 过期扫描

- 增强 Redis MQ 多实例 consumer 标识、pending reclaim 和单条处理能力

- 增加文档导入状态 Redis 广播和 Agent HITL 跨节点路由确认
2026-05-29 18:27:46 +08:00
cc3bb9cff0 feat: 完成 Agent MCP 对接
- 增加 MCP 连接类型、环境检测接口和容器运行环境支持

- 将 Agent 编排改为绑定整体 MCP 并编译为 runtime McpSpec

- 优化 MCP 工具展示、审批、草稿试运行和画布回显稳定性
2026-05-29 11:09:21 +08:00
e39f7521e2 chore: 弃用 bot 对接的 mcptool 2026-05-28 11:30:56 +08:00
1c205c3720 feat: 先进智能体功能上线
- 基于 agent-runtime 打造,默认 ReAct agent
- 支持 agent 能力对接,已对接工作流、插件、知识库等 tool 能力
- 全新 agent 编排界面,支持可视化便捷配置 agent
- 全新 agent 聊天界面,支持快捷操作、额外知识库选择等
2026-05-28 11:29:18 +08:00
11e595b088 perf: 收敛后端资源与健康检查开销
- 缩小模块扫描范围并显式注册各业务模块自动配置

- 增加可配置线程池、MQ 连接池与消费线程池,降低默认资源占用

- 将 RAG 与分析库中间件探活下沉到健康检查并增加短缓存

- 补齐文档向量库生命周期释放与 SSE 断连清理
2026-05-28 11:22:14 +08:00
72df00f25b feat: 全新智能体功能
- 基于先进智能体框架,增加智能体编排功能
- 增加智能体聊天,并对接持久化
2026-05-25 11:42:48 +08:00
1997 changed files with 302673 additions and 10717 deletions

2
.gitignore vendored
View File

@@ -35,6 +35,8 @@ build/
### Mac OS ### ### Mac OS ###
.DS_Store .DS_Store
/.logs/ /.logs/
/logs/
/agent-workspaces/
/.idea/ /.idea/
.logs .logs
.idea .idea

View File

@@ -1,4 +1,5 @@
FROM --platform=linux/amd64 swr.cn-north-4.myhuaweicloud.com/ddn-k8s/docker.io/eclipse-temurin:17-jre # 后端构建脚本
FROM swr.cn-north-4.myhuaweicloud.com/ddn-k8s/docker.io/eclipse-temurin:17-jre
ENV LANG=C.UTF-8 ENV LANG=C.UTF-8
ENV LC_ALL=C.UTF-8 ENV LC_ALL=C.UTF-8
@@ -8,18 +9,129 @@ ENV EASYFLOW_JAR_PATH=/app/artifacts/easyflow.jar
ENV EASYFLOW_CONFIG_PATH=file:/app/application.yml ENV EASYFLOW_CONFIG_PATH=file:/app/application.yml
ENV EASYFLOW_LOG_FILE=/app/logs/app.log ENV EASYFLOW_LOG_FILE=/app/logs/app.log
ENV EASYFLOW_JAR_RESTART_GRACE_SECONDS=30 ENV EASYFLOW_JAR_RESTART_GRACE_SECONDS=30
ENV NPM_CONFIG_REGISTRY=https://registry.npmjs.org
ENV PIP_INDEX_URL=https://pypi.org/simple
ENV PYTHONPATH=/opt/easyflow/python-packages
ENV NODE_PATH=/app/node_modules
WORKDIR /app WORKDIR /app
ARG DEBIAN_FRONTEND=noninteractive
RUN useradd --system --create-home easyflow && \ RUN useradd --system --create-home easyflow && \
apt-get update && \ apt-get update && \
apt-get install -y --no-install-recommends python3 inotify-tools tini && \ apt-get install -y --no-install-recommends \
rm -rf /var/lib/apt/lists/* && \ ca-certificates \
curl \
gnupg && \
mkdir -p /etc/apt/keyrings && \
curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key -o /tmp/nodesource.gpg.key && \
gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg /tmp/nodesource.gpg.key && \
chmod 644 /etc/apt/keyrings/nodesource.gpg && \
printf "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_24.x nodistro main\n" > /etc/apt/sources.list.d/nodesource.list && \
rm -f /tmp/nodesource.gpg.key && \
apt-get update && \
apt-get install -y --no-install-recommends \
coreutils \
diffutils \
file \
findutils \
fontconfig \
fonts-liberation2 \
fonts-noto-cjk \
gawk \
grep \
gzip \
inotify-tools \
jq \
libdigest-sha-perl \
libreoffice-calc \
libreoffice-impress \
libreoffice-writer \
nodejs \
pandoc \
poppler-utils \
procps \
python3 \
python3-pip \
python3-venv \
qpdf \
ripgrep \
sed \
tar \
tree \
unzip \
util-linux \
zip \
tini && \
rm -rf /var/lib/apt/lists/*
RUN mkdir -p /etc/pip "${PYTHONPATH}" /opt/easyflow/node-runtime && \
ln -sf /usr/bin/python3 /usr/local/bin/python && \
ln -sf /usr/bin/pip3 /usr/local/bin/pip && \
npm config set registry "${NPM_CONFIG_REGISTRY}" && \
printf "registry=%s\n" "${NPM_CONFIG_REGISTRY}" > /etc/npmrc && \
npm install -g pnpm@10.17.1 && \
pnpm config set registry "${NPM_CONFIG_REGISTRY}" && \
printf "[global]\nindex-url = %s\n" "${PIP_INDEX_URL}" > /etc/pip.conf
RUN python3 -m pip install --no-cache-dir --target "${PYTHONPATH}" \
python-docx==1.2.0 \
python-pptx==1.0.2 \
openpyxl==3.1.5 \
xlsxwriter==3.2.9 \
lxml==6.1.1 \
defusedxml==0.7.1 \
pillow==12.3.0 \
pypdf==6.16.1 \
pdfplumber==0.11.10 \
pdf2image==1.17.0 \
reportlab==5.0.0 \
numpy==2.5.2 \
pandas==3.0.5 \
matplotlib==3.11.1 \
seaborn==0.13.2 \
pyyaml==6.0.3 \
jsonschema==4.26.0 \
jinja2==3.1.6 \
beautifulsoup4==4.15.0 \
pydantic==2.13.4 \
python-dateutil==2.9.0.post0 \
tabulate==0.10.0 \
markdown==3.10.3 \
charset-normalizer==3.5.1 \
tenacity==9.1.4 && \
PYTHONPATH="${PYTHONPATH}" python3 -c "import bs4, defusedxml, docx, jsonschema, lxml, matplotlib, numpy, openpyxl, pandas, pdfplumber, PIL, pptx, pydantic, pypdf, reportlab, seaborn, yaml"
RUN npm install --prefix /opt/easyflow/node-runtime --omit=dev --no-audit --no-fund --save-exact \
docx@9.7.1 \
pptxgenjs@4.0.1 \
sharp@0.35.3 \
pdf-lib@1.17.1 \
pdfjs-dist@6.2.108 \
zod@4.4.3 \
ajv@8.20.0 \
yaml@2.9.0 \
csv-parse@7.0.2 \
csv-stringify@6.8.3 \
fast-xml-parser@5.10.1 \
marked@18.0.9 \
sanitize-html@2.17.7 \
cheerio@1.2.0 \
dayjs@1.11.21 \
handlebars@4.7.9 \
jszip@3.10.1 && \
ln -s /opt/easyflow/node-runtime/node_modules /app/node_modules && \
node -e "for (const name of ['docx','pptxgenjs','sharp','pdf-lib','pdfjs-dist/package.json','zod','ajv','yaml','csv-parse','csv-stringify','fast-xml-parser','marked','sanitize-html','cheerio','dayjs','handlebars','jszip']) require.resolve(name)" && \
npm cache clean --force
RUN fc-cache -f && \
mkdir -p /app/logs /app/artifacts /app/data && \ mkdir -p /app/logs /app/artifacts /app/data && \
chown -R easyflow:easyflow /app chown -R easyflow:easyflow /app
COPY docker-soffice-wrapper.sh /usr/local/bin/soffice
COPY docker-entrypoint.sh /usr/local/bin/easyflow-entrypoint.sh COPY docker-entrypoint.sh /usr/local/bin/easyflow-entrypoint.sh
RUN chmod 755 /usr/local/bin/easyflow-entrypoint.sh RUN chmod 755 /usr/local/bin/soffice /usr/local/bin/easyflow-entrypoint.sh
VOLUME ["/app/logs", "/app/data"] VOLUME ["/app/logs", "/app/data"]
EXPOSE 8111 EXPOSE 8111

View File

@@ -58,6 +58,14 @@ pnpm install
pnpm dev pnpm dev
``` ```
管理端开发环境与生产环境统一使用 `/flow/` 基路径和 Hash 路由,默认访问地址:
```text
http://127.0.0.1:5090/flow/#/
```
直接访问 `http://127.0.0.1:5090``/flow` 时,开发服务器会自动补齐 `/flow/`
用户中心: 用户中心:
```bash ```bash
@@ -68,6 +76,13 @@ pnpm dev
默认测试账号:`admin / Easy@2026` 默认测试账号:`admin / Easy@2026`
管理端发布前可执行以下命令,验证环境契约、生产镜像和 Nginx 路由:
```bash
cd easyflow-ui-admin
pnpm verify:deployment
```
## 后端 Jar 包构建与部署 ## 后端 Jar 包构建与部署
### 构建 Jar ### 构建 Jar

View File

@@ -0,0 +1,61 @@
-dontshrink
-dontoptimize
-dontpreverify
-ignorewarnings
-dontnote
-libraryjars <java.home>/jmods/java.base.jmod(!**.jar;!module-info.class)
-libraryjars <java.home>/jmods/java.compiler.jmod(!**.jar;!module-info.class)
-libraryjars <java.home>/jmods/java.datatransfer.jmod(!**.jar;!module-info.class)
-libraryjars <java.home>/jmods/java.desktop.jmod(!**.jar;!module-info.class)
-libraryjars <java.home>/jmods/java.instrument.jmod(!**.jar;!module-info.class)
-libraryjars <java.home>/jmods/java.logging.jmod(!**.jar;!module-info.class)
-libraryjars <java.home>/jmods/java.management.jmod(!**.jar;!module-info.class)
-libraryjars <java.home>/jmods/java.naming.jmod(!**.jar;!module-info.class)
-libraryjars <java.home>/jmods/java.net.http.jmod(!**.jar;!module-info.class)
-libraryjars <java.home>/jmods/java.prefs.jmod(!**.jar;!module-info.class)
-libraryjars <java.home>/jmods/java.rmi.jmod(!**.jar;!module-info.class)
-libraryjars <java.home>/jmods/java.scripting.jmod(!**.jar;!module-info.class)
-libraryjars <java.home>/jmods/java.security.jgss.jmod(!**.jar;!module-info.class)
-libraryjars <java.home>/jmods/java.security.sasl.jmod(!**.jar;!module-info.class)
-libraryjars <java.home>/jmods/java.sql.jmod(!**.jar;!module-info.class)
-libraryjars <java.home>/jmods/java.transaction.xa.jmod(!**.jar;!module-info.class)
-libraryjars <java.home>/jmods/java.xml.jmod(!**.jar;!module-info.class)
-libraryjars <java.home>/jmods/java.xml.crypto.jmod(!**.jar;!module-info.class)
-keepattributes RuntimeVisibleAnnotations,RuntimeInvisibleAnnotations,RuntimeVisibleParameterAnnotations,RuntimeInvisibleParameterAnnotations,AnnotationDefault,Signature,InnerClasses,EnclosingMethod,Record,SourceFile,LineNumberTable,MethodParameters
-keep @org.springframework.stereotype.Controller class * { *; }
-keep @org.springframework.web.bind.annotation.RestController class * { *; }
-keep @org.springframework.context.annotation.Configuration class * { *; }
-keep @org.springframework.boot.context.properties.ConfigurationProperties class * { *; }
-keep @org.springframework.boot.autoconfigure.SpringBootApplication class * { *; }
-keep class **.*Controller { *; }
-keep class **.*Mapper { *; }
-keep class **.mapper.** { *; }
-keep class **.entity.** { *; }
-keep class **.dto.** { *; }
-keep class **.vo.** { *; }
-keep class **.model.** { *; }
-keep class **.config.** { *; }
-keep class **.enums.** { *; }
-keep class **.annotation.** { *; }
-keep class **.*Exception { *; }
-keep class **.*ErrorCode { *; }
-keep class **.*Properties { *; }
-keep class **.*Config { *; }
-keep class **.*Configuration { *; }
-keep interface tech.easyflow.** { *; }
-keep enum tech.easyflow.** { *; }
-keepclassmembers class * {
@jakarta.annotation.Resource <fields>;
@org.springframework.beans.factory.annotation.Autowired <fields>;
@org.springframework.beans.factory.annotation.Value <fields>;
@org.springframework.context.annotation.Bean <methods>;
}
-keepclassmembers class * {
public <init>(...);
}

View File

@@ -0,0 +1,28 @@
-include ../../config/proguard/common-keep.pro
-keep class tech.easyflow.ai.chattime.** { *; }
-keep class tech.easyflow.ai.constants.** { *; }
-keep class tech.easyflow.ai.document.** { *; }
-keep class tech.easyflow.ai.documentimport.** { *; }
-keep class tech.easyflow.ai.easyagents.** { *; }
-keep class tech.easyflow.ai.exception.** { *; }
-keep class tech.easyflow.ai.mcp.** { *; }
-keep class tech.easyflow.ai.node.** { *; }
-keep class tech.easyflow.ai.permission.** { *; }
-keep class tech.easyflow.ai.plugin.** { *; }
-keep class tech.easyflow.ai.publish.** { *; }
-keep class tech.easyflow.ai.rag.** { *; }
-keep class tech.easyflow.ai.service.** { *; }
-keep class tech.easyflow.ai.support.** { *; }
-keep class tech.easyflow.ai.utils.** { *; }
-keep class tech.easyflow.ai.invoke.service.** { *; }
-keep class tech.easyflow.ai.invoke.model.** { *; }
-keep class tech.easyflow.ai.invoke.protocol.** { *; }
-keep class tech.easyflow.ai.invoke.exception.** { *; }
-keep class tech.easyflow.ai.invoke.mapper.OpenAiProtocolMapper { *; }
-keep class tech.easyflow.ai.invoke.provider.ModelProviderGateway { *; }
-keep class tech.easyflow.ai.invoke.provider.UnifiedChatChunkObserver { *; }
-keep class tech.easyflow.ai.easyagentsflow.config.** { *; }
-keep class tech.easyflow.ai.easyagentsflow.entity.** { *; }
-keep class tech.easyflow.ai.easyagentsflow.service.** { *; }
-keep class tech.easyflow.ai.easyagentsflow.support.** { *; }

View File

@@ -0,0 +1,5 @@
-include ../../config/proguard/common-keep.pro
-keep class tech.easyflow.autoconfig.license.EasyflowLicenseBootstrapValidator { *; }
-keep class tech.easyflow.autoconfig.license.EasyflowLicenseProperties { *; }
-keep class tech.easyflow.autoconfig.license.EasyflowLicenseVerificationResult { *; }

View File

@@ -0,0 +1,10 @@
-include ../../config/proguard/common-keep.pro
-keep class tech.easyflow.datacenter.connector.DatacenterConnector { *; }
-keep class tech.easyflow.datacenter.connector.QueryExecutor { *; }
-keep class tech.easyflow.datacenter.connector.WriteExecutor { *; }
-keep class tech.easyflow.datacenter.connector.MetadataExplorer { *; }
-keep class tech.easyflow.datacenter.connector.SourceHealthChecker { *; }
-keep class tech.easyflow.datacenter.connector.SqlDialect { *; }
-keep class tech.easyflow.datacenter.execution.model.** { *; }
-keep class tech.easyflow.datacenter.meta.enums.** { *; }

View File

@@ -72,6 +72,8 @@ services:
TZ: Asia/Shanghai TZ: Asia/Shanghai
MINIO_ROOT_USER: easyflowadmin MINIO_ROOT_USER: easyflowadmin
MINIO_ROOT_PASSWORD: easyflowadmin123 MINIO_ROOT_PASSWORD: easyflowadmin123
MINIO_API_STALE_UPLOADS_EXPIRY: 24h
MINIO_API_STALE_UPLOADS_CLEANUP_INTERVAL: 6h
ports: ports:
- "9000:9000" - "9000:9000"
- "9001:9001" - "9001:9001"
@@ -88,7 +90,7 @@ services:
MINIO_ROOT_USER: easyflowadmin MINIO_ROOT_USER: easyflowadmin
MINIO_ROOT_PASSWORD: easyflowadmin123 MINIO_ROOT_PASSWORD: easyflowadmin123
MINIO_ENDPOINT: http://minio:9000 MINIO_ENDPOINT: http://minio:9000
MINIO_BUCKETS: easyflow,milvus MINIO_BUCKETS: easyflow,milvus,easyflow-agent-media
MINIO_PUBLIC_BUCKETS: easyflow MINIO_PUBLIC_BUCKETS: easyflow
MINIO_ALIAS: local MINIO_ALIAS: local
volumes: volumes:

View File

@@ -22,6 +22,7 @@ services:
- easyflow-net - easyflow-net
volumes: volumes:
- ./attachment:/www/easyflow/attachment - ./attachment:/www/easyflow/attachment
- ./logs:/app/logs
depends_on: depends_on:
mysql: mysql:
condition: service_healthy condition: service_healthy

21
docker-soffice-wrapper.sh Normal file
View File

@@ -0,0 +1,21 @@
#!/usr/bin/env sh
set -eu
profile_parent="${TMPDIR:-/tmp}"
profile_dir="$(mktemp -d "${profile_parent%/}/easyflow-soffice-XXXXXX")"
cleanup() {
rm -rf -- "$profile_dir"
}
trap cleanup EXIT HUP INT TERM
/usr/bin/soffice \
-env:UserInstallation="file://${profile_dir}" \
--headless \
--safe-mode \
--nologo \
--nodefault \
--nolockcheck \
--norestore \
"$@"

View File

@@ -20,6 +20,14 @@
<groupId>tech.easyflow</groupId> <groupId>tech.easyflow</groupId>
<artifactId>easyflow-module-ai</artifactId> <artifactId>easyflow-module-ai</artifactId>
</dependency> </dependency>
<dependency>
<groupId>tech.easyflow</groupId>
<artifactId>easyflow-module-agent</artifactId>
</dependency>
<dependency>
<groupId>tech.easyflow</groupId>
<artifactId>easyflow-module-skill</artifactId>
</dependency>
<dependency> <dependency>
<groupId>tech.easyflow</groupId> <groupId>tech.easyflow</groupId>
<artifactId>easyflow-module-chatlog</artifactId> <artifactId>easyflow-module-chatlog</artifactId>
@@ -32,6 +40,10 @@
<groupId>tech.easyflow</groupId> <groupId>tech.easyflow</groupId>
<artifactId>easyflow-module-job</artifactId> <artifactId>easyflow-module-job</artifactId>
</dependency> </dependency>
<dependency>
<groupId>tech.easyflow</groupId>
<artifactId>easyflow-module-dataspace</artifactId>
</dependency>
<dependency> <dependency>
<groupId>tech.easyflow</groupId> <groupId>tech.easyflow</groupId>
<artifactId>easyflow-common-captcha</artifactId> <artifactId>easyflow-common-captcha</artifactId>

View File

@@ -0,0 +1,109 @@
package tech.easyflow.admin.controller.agent;
import cn.dev33.satoken.annotation.SaCheckPermission;
import cn.dev33.satoken.annotation.SaMode;
import org.springframework.http.CacheControl;
import org.springframework.http.ContentDisposition;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody;
import tech.easyflow.agent.entity.AgentArtifact;
import tech.easyflow.agent.runtime.artifact.AgentArtifactService;
import tech.easyflow.agent.runtime.artifact.AgentArtifactView;
import tech.easyflow.common.domain.Result;
import tech.easyflow.common.entity.LoginAccount;
import tech.easyflow.common.satoken.util.SaTokenUtil;
import tech.easyflow.common.web.exceptions.BusinessException;
import java.io.InputStream;
import java.math.BigInteger;
import java.nio.charset.StandardCharsets;
/**
* Agent Artifact 安全元数据与鉴权下载控制器。
*/
@RestController
@RequestMapping("/api/v1/agent/artifacts")
public class AgentArtifactController {
private final AgentArtifactService artifactService;
/**
* 创建 Artifact 控制器。
*
* @param artifactService Artifact 服务
*/
public AgentArtifactController(AgentArtifactService artifactService) {
this.artifactService = artifactService;
}
/**
* 查询一个已鉴权 Artifact 的安全元数据。
*
* @param artifactId 稳定 Artifact ID
* @return 安全元数据
*/
@GetMapping("/{artifactId}")
@SaCheckPermission(value = {"/api/v1/agent/session/query", "/api/v1/agent/save"}, mode = SaMode.OR)
public Result<AgentArtifactView> metadata(@PathVariable String artifactId,
@RequestParam BigInteger agentId,
@RequestParam String mode,
@RequestParam(required = false) BigInteger sessionId,
@RequestParam(required = false) String runtimeSessionId) {
AgentArtifact artifact = artifactService.requireDownload(
artifactId, requireAccount(), agentId, mode, sessionId, runtimeSessionId);
return Result.ok(artifactService.toView(artifact));
}
/**
* 通过后端鉴权代理流式下载私有 Artifact。
*
* @param artifactId 稳定 Artifact ID
* @return 私有流式响应
*/
@GetMapping("/{artifactId}/content")
@SaCheckPermission(value = {"/api/v1/agent/session/query", "/api/v1/agent/save"}, mode = SaMode.OR)
public ResponseEntity<StreamingResponseBody> content(@PathVariable String artifactId,
@RequestParam BigInteger agentId,
@RequestParam String mode,
@RequestParam(required = false) BigInteger sessionId,
@RequestParam(required = false) String runtimeSessionId) {
AgentArtifact artifact = artifactService.requireDownload(
artifactId, requireAccount(), agentId, mode, sessionId, runtimeSessionId);
StreamingResponseBody body = output -> {
try (InputStream input = artifactService.openDownload(artifact)) {
input.transferTo(output);
}
};
String mimeType = artifact.getMimeType() == null
? MediaType.APPLICATION_OCTET_STREAM_VALUE : artifact.getMimeType();
return ResponseEntity.ok()
.cacheControl(CacheControl.noStore())
.header(HttpHeaders.CONTENT_DISPOSITION, ContentDisposition.attachment()
.filename(artifact.getFileName(), StandardCharsets.UTF_8).build().toString())
.header("X-Content-Type-Options", "nosniff")
.contentType(MediaType.parseMediaType(mimeType))
.contentLength(artifact.getSizeBytes() == null ? 0L : artifact.getSizeBytes())
.body(body);
}
private LoginAccount requireAccount() {
try {
LoginAccount account = SaTokenUtil.getLoginAccount();
if (account == null || account.getId() == null || account.getTenantId() == null) {
throw new BusinessException("当前登录状态失效,请重新登录后再试");
}
return account;
} catch (BusinessException error) {
throw error;
} catch (Exception error) {
throw new BusinessException("当前登录状态失效,请重新登录后再试");
}
}
}

View File

@@ -0,0 +1,87 @@
package tech.easyflow.admin.controller.agent;
import com.mybatisflex.core.query.QueryWrapper;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import tech.easyflow.agent.entity.Agent;
import tech.easyflow.agent.entity.AgentCategory;
import tech.easyflow.agent.mapper.AgentMapper;
import tech.easyflow.agent.service.AgentCategoryService;
import tech.easyflow.common.annotation.UsePermission;
import tech.easyflow.common.domain.Result;
import tech.easyflow.common.web.controller.BaseCurdController;
import tech.easyflow.common.web.exceptions.BusinessException;
import tech.easyflow.system.entity.vo.RoleCategoryAccessSnapshot;
import tech.easyflow.system.enums.CategoryResourceType;
import tech.easyflow.system.service.CategoryPermissionService;
import javax.annotation.Resource;
import java.io.Serializable;
import java.util.Collection;
import java.util.Collections;
import java.util.List;
/**
* Agent 分类管理控制器。
*/
@RestController
@RequestMapping("/api/v1/agentCategory")
@UsePermission(moduleName = "/api/v1/agent")
public class AgentCategoryController extends BaseCurdController<AgentCategoryService, AgentCategory> {
@Resource
private AgentMapper agentMapper;
@Resource
private CategoryPermissionService categoryPermissionService;
/**
* 创建 Agent 分类管理控制器。
*
* @param service Agent 分类服务
*/
public AgentCategoryController(AgentCategoryService service) {
super(service);
}
/**
* 查询当前用户可见的 Agent 分类。
*
* @param entity 查询条件
* @param asTree 是否转树
* @param sortKey 排序字段
* @param sortType 排序方式
* @return 可见分类列表
*/
@GetMapping("visibleList")
public Result<List<AgentCategory>> visibleList(AgentCategory entity, Boolean asTree, String sortKey, String sortType) {
QueryWrapper queryWrapper = QueryWrapper.create(entity, buildOperators(entity));
RoleCategoryAccessSnapshot access = categoryPermissionService.getCurrentAccess(CategoryResourceType.AGENT.getCode());
if (access.isRestricted()) {
if (access.getCategoryIds().isEmpty()) {
return Result.ok(Collections.emptyList());
}
queryWrapper.in("id", access.getCategoryIds());
}
queryWrapper.orderBy(buildOrderBy(sortKey, sortType, getDefaultOrderBy()));
return Result.ok(service.list(queryWrapper));
}
/**
* 删除分类前校验是否仍被 Agent 使用。
*
* @param ids 分类 ID 集合
* @return 校验结果
*/
@Override
protected Result<?> onRemoveBefore(Collection<Serializable> ids) {
for (Serializable id : ids) {
QueryWrapper queryWrapper = QueryWrapper.create().eq(Agent::getCategoryId, id);
List<Agent> agents = agentMapper.selectListByQuery(queryWrapper);
if (agents != null && !agents.isEmpty()) {
throw new BusinessException("请先删除该分类下的所有 Agent");
}
}
return super.onRemoveBefore(ids);
}
}

View File

@@ -0,0 +1,848 @@
package tech.easyflow.admin.controller.agent;
import cn.dev33.satoken.annotation.SaCheckPermission;
import cn.dev33.satoken.annotation.SaMode;
import com.mybatisflex.core.paginate.Page;
import com.mybatisflex.core.query.QueryWrapper;
import io.agentscope.core.agui.model.RunAgentInput;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.http.ContentDisposition;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody;
import tech.easyflow.admin.controller.ai.support.AiResourceCreatorNameSupport;
import tech.easyflow.agent.entity.Agent;
import tech.easyflow.agent.entity.AgentKnowledgeBinding;
import tech.easyflow.agent.entity.AgentToolBinding;
import tech.easyflow.agent.publish.AgentPublishAppService;
import tech.easyflow.agent.runtime.AgentChatRequest;
import tech.easyflow.agent.runtime.AgentDraftChatRequest;
import tech.easyflow.agent.runtime.AgentRunService;
import tech.easyflow.agent.runtime.agui.AgentAguiHitlResolveRequest;
import tech.easyflow.agent.runtime.agui.AgentAguiRunStatusView;
import tech.easyflow.agent.runtime.composer.AgentComposerDraft;
import tech.easyflow.agent.runtime.composer.AgentComposerDraftService;
import tech.easyflow.agent.runtime.composer.AgentComposerSession;
import tech.easyflow.agent.runtime.document.AgentDocumentResource;
import tech.easyflow.agent.runtime.document.AgentDocumentService;
import tech.easyflow.agent.runtime.document.AgentDocumentUploadView;
import tech.easyflow.agent.runtime.media.AgentMediaService;
import tech.easyflow.agent.runtime.media.AgentMediaUploadView;
import com.easyagents.agent.runtime.media.AgentMediaResource;
import tech.easyflow.agent.security.AgentVisibilityQueryHelper;
import tech.easyflow.agent.service.AgentApprovalStateService;
import tech.easyflow.agent.service.AgentKnowledgeBindingService;
import tech.easyflow.agent.service.AgentOptionQueryService;
import tech.easyflow.agent.service.AgentService;
import tech.easyflow.agent.service.AgentSkillBindingService;
import tech.easyflow.agent.service.AgentToolBindingService;
import tech.easyflow.agent.vo.AgentOptionView;
import tech.easyflow.agent.vo.AgentResourceOptionsView;
import tech.easyflow.ai.enums.PublishStatus;
import tech.easyflow.approval.entity.vo.ApprovalActionResult;
import tech.easyflow.common.domain.Result;
import tech.easyflow.common.entity.LoginAccount;
import tech.easyflow.common.web.controller.BaseCurdController;
import tech.easyflow.common.web.exceptions.BusinessException;
import tech.easyflow.common.web.jsonbody.JsonBody;
import tech.easyflow.common.satoken.util.SaTokenUtil;
import tech.easyflow.log.annotation.LogReporterDisabled;
import javax.annotation.Resource;
import java.io.Serializable;
import java.math.BigInteger;
import java.nio.charset.StandardCharsets;
import java.util.Collection;
import java.util.Collections;
import java.util.List;
/**
* Agent 管理端控制器。
*/
@RestController
@RequestMapping("/api/v1/agent")
public class AgentController extends BaseCurdController<AgentService, Agent> {
@Resource
private AgentToolBindingService agentToolBindingService;
@Resource
private AgentKnowledgeBindingService agentKnowledgeBindingService;
@Resource
private AgentSkillBindingService agentSkillBindingService;
@Resource
private AgentRunService agentRunService;
@Resource
private AgentPublishAppService agentPublishAppService;
@Resource
private AgentApprovalStateService agentApprovalStateService;
@Resource
private AiResourceCreatorNameSupport aiResourceCreatorNameSupport;
@Resource
private AgentMediaService agentMediaService;
@Resource
private AgentDocumentService agentDocumentService;
@Resource
private AgentComposerDraftService agentComposerDraftService;
@Resource
private AgentOptionQueryService agentOptionQueryService;
@Resource
private AgentVisibilityQueryHelper agentVisibilityQueryHelper;
/**
* 创建 Agent 控制器。
*
* @param service Agent 服务
*/
public AgentController(AgentService service) {
super(service);
}
/**
* 获取 Agent 列表关键字搜索字段。
*
* @return Agent 名称和描述属性
*/
@Override
protected String[] getKeywordSearchProperties() {
return new String[]{"name", "description"};
}
/**
* 获取 Agent 详情。
*
* @param id Agent ID
* @return Agent 详情
*/
@GetMapping("/getDetail")
public Result<AgentDetailView> getDetail(BigInteger id) {
Agent agent = service.getDetail(id);
agentApprovalStateService.fillAgentApprovalState(agent);
aiResourceCreatorNameSupport.fillAgentCreatorNames(List.of(agent));
return Result.ok(AgentDetailView.from(agent));
}
/**
* 保存 Agent 草稿。
*
* @param agent Agent 草稿
* @return Agent 详情
*/
@Override
@PostMapping("save")
public Result<?> save(@JsonBody Agent agent) {
Agent saved = service.saveDraft(agent);
return Result.ok(AgentDetailView.from(service.getDetail(saved.getId())));
}
/**
* 更新 Agent 草稿。
*
* @param agent Agent 草稿
* @return Agent 详情
*/
@Override
@PostMapping("update")
public Result<?> update(@JsonBody Agent agent) {
Agent saved = service.updateDraft(agent);
return Result.ok(AgentDetailView.from(service.getDetail(saved.getId())));
}
/**
* 原子保存 Agent 草稿及本次发生变化的绑定组。
*
* @param request 设计器保存请求
* @return 保存后的 Agent 与本次替换的绑定
*/
@PostMapping("/draft/save")
@SaCheckPermission("/api/v1/agent/save")
public Result<AgentDetailView> saveDraft(@JsonBody(required = true, skipConvertError = false)
AgentDraftSaveRequest request) {
if (request == null || request.getAgent() == null) {
throw new BusinessException("Agent 草稿不能为空");
}
Agent saved = service.saveDraftGraph(
request.getAgent(),
request.getToolBindings(),
request.isReplaceToolBindings(),
request.getKnowledgeBindings(),
request.isReplaceKnowledgeBindings(),
request.toSkillBindings(),
request.isReplaceSkillBindings());
return Result.ok(AgentDetailView.from(saved));
}
/**
* 更新 Agent 可见范围。
*
* @param agent 包含 Agent ID 和可见范围的请求数据
* @return 更新后的 Agent
*/
@PostMapping("visibilityScope/update")
@SaCheckPermission("/api/v1/agent/save")
public Result<AgentDetailView> updateVisibilityScope(@JsonBody Agent agent) {
return Result.ok(AgentDetailView.from(
service.updateVisibilityScope(agent.getId(), agent.getVisibilityScope())));
}
/**
* 查询 Agent 列表。
*
* @param entity 查询条件
* @param asTree 是否转树
* @param sortKey 排序字段
* @param sortType 排序方式
* @return Agent 列表
*/
@Override
public Result<List<Agent>> list(Agent entity, Boolean asTree, String sortKey, String sortType) {
HttpServletRequest request = currentRequest();
QueryWrapper queryWrapper = request == null ? QueryWrapper.create() : buildQueryWrapper(request);
agentVisibilityQueryHelper.applyReadableAccess(queryWrapper);
applyPublishedOnlyFilter(queryWrapper);
queryWrapper.orderBy(buildOrderBy(sortKey, sortType, getDefaultOrderBy()));
List<Agent> agents = service.list(queryWrapper);
if (isPublishedOnlyRequest()) {
agents = agents.stream().map(agent -> service.fromSnapshot(agent.getPublishedSnapshotJson())).toList();
}
agents.forEach(this::sanitizeListItem);
agentApprovalStateService.fillAgentApprovalState(agents);
aiResourceCreatorNameSupport.fillAgentCreatorNames(agents);
return Result.ok(agents);
}
/**
* 查询 Agent 安全选择项。
*
* @param publishedOnly 是否仅返回已发布 Agent
* @return Agent 安全选择项
*/
@GetMapping("/options")
@SaCheckPermission("/api/v1/agent/query")
public Result<List<AgentOptionView>> options(
@RequestParam(value = "publishedOnly", defaultValue = "false") boolean publishedOnly) {
return Result.ok(agentOptionQueryService.listAgentOptions(publishedOnly));
}
/**
* 查询 Agent 设计器的安全资源选项。
*
* @return 设计器资源选项
*/
@GetMapping("/resourceOptions")
@SaCheckPermission("/api/v1/agent/save")
public Result<AgentResourceOptionsView> resourceOptions() {
return Result.ok(agentOptionQueryService.listDesignerResourceOptions());
}
/**
* 查询 Agent 会话可使用的知识库安全选项。
*
* @return 知识库选项
*/
@GetMapping("/knowledgeOptions")
@SaCheckPermission("/api/v1/agent/query")
public Result<List<AgentResourceOptionsView.ResourceOption>> knowledgeOptions() {
return Result.ok(agentOptionQueryService.listKnowledgeOptions());
}
/**
* 查询指定 MCP 的安全工具列表。
*
* @param id MCP ID
* @return MCP 工具列表
*/
@GetMapping("/mcpToolOptions")
@SaCheckPermission("/api/v1/agent/save")
public Result<List<AgentResourceOptionsView.McpToolOption>> mcpToolOptions(
@RequestParam BigInteger id) {
return Result.ok(agentOptionQueryService.listMcpTools(id));
}
/**
* 运行 Agent 纯文本聊天。
*
* @param request 聊天请求
* @return SSE Emitter
*/
@PostMapping("chat")
@SaCheckPermission("/api/v1/agent/session/query")
public SseEmitter chat(@JsonBody AgentChatRequest request) {
return agentRunService.chat(request);
}
/**
* 运行 Agent 草稿态纯文本试用。
*
* @param request 草稿试用请求
* @return SSE Emitter
*/
@PostMapping("/chat/draft")
@SaCheckPermission("/api/v1/agent/save")
public SseEmitter chatDraft(@JsonBody AgentDraftChatRequest request) {
return agentRunService.chatDraft(request);
}
/**
* 通过 AG-UI 协议运行正式 Agent 聊天。
*
* @param agentId URL 中的 Agent ID
* @param input AG-UI 运行输入
* @return 原生 AG-UI SSE
*/
@PostMapping("/{agentId}/agui/run")
@SaCheckPermission("/api/v1/agent/session/query")
public SseEmitter chatAgui(@PathVariable BigInteger agentId,
@RequestBody RunAgentInput input) {
return agentRunService.chatAgui(agentId, input);
}
/**
* 通过 AG-UI 协议运行草稿 Agent 试用。
*
* @param input AG-UI 运行输入
* @return 原生 AG-UI SSE
*/
@PostMapping("/agui/run/draft")
@SaCheckPermission("/api/v1/agent/save")
public SseEmitter chatDraftAgui(@RequestBody RunAgentInput input) {
return agentRunService.chatDraftAgui(input);
}
/**
* 查询可重连 AG-UI 运行状态。
*
* @param runId 客户端运行 ID
* @return 运行状态
*/
@GetMapping("/agui/run/{runId}/status")
@SaCheckPermission(value = {
"/api/v1/agent/session/query", "/api/v1/agent/save"
}, mode = SaMode.OR)
public Result<AgentAguiRunStatusView> getAguiRunStatus(@PathVariable String runId) {
return Result.ok(agentRunService.getAguiRunStatus(runId));
}
/**
* 从指定游标继续订阅 AG-UI 运行事件。
*
* @param runId 客户端运行 ID
* @param after 已消费的最后事件游标
* @return 增量重放 SSE
*/
@GetMapping("/agui/run/{runId}/events")
@SaCheckPermission(value = {
"/api/v1/agent/session/query", "/api/v1/agent/save"
}, mode = SaMode.OR)
public SseEmitter subscribeAguiRun(@PathVariable String runId,
@RequestParam(defaultValue = "0") long after) {
return agentRunService.subscribeAguiRun(runId, after);
}
/**
* 显式取消单次 AG-UI 运行。
*
* @param runId 客户端运行 ID
* @return 操作结果
*/
@PostMapping("/agui/run/{runId}/cancel")
@SaCheckPermission(value = {
"/api/v1/agent/session/query", "/api/v1/agent/save"
}, mode = SaMode.OR)
public Result<Void> cancelAguiRun(@PathVariable String runId) {
agentRunService.cancelAguiRun(runId);
return Result.ok();
}
/**
* 处理 AG-UI 自定义 HITL 兼容桥审批。
*
* @param request 审批请求
* @return 操作结果
*/
@PostMapping("/agui/hitl/resolve")
@SaCheckPermission(value = {
"/api/v1/agent/session/query", "/api/v1/agent/save"
}, mode = SaMode.OR)
public Result<Void> resolveAguiApproval(@RequestBody AgentAguiHitlResolveRequest request) {
agentRunService.resolveAguiApproval(request);
return Result.ok();
}
/**
* 上传一张 Agent 聊天临时图片。
*
* @param file 图片文件
* @param mode 聊天模式
* @param agentId Agent ID
* @param sessionId 会话 ID
* @return 上传结果
*/
@PostMapping(value = "/media/upload", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@SaCheckPermission(value = {
"/api/v1/agent/session/query", "/api/v1/agent/save"
}, mode = SaMode.OR)
public Result<AgentMediaUploadView> uploadMedia(@RequestParam("file") MultipartFile file,
@RequestParam("mode") String mode,
@RequestParam("agentId") String agentId,
@RequestParam("sessionId") String sessionId) {
return Result.ok(agentMediaService.upload(file, mode, agentId, sessionId, SaTokenUtil.getLoginAccount()));
}
/**
* 删除当前账号尚未发送的临时图片。
*
* @param uploadId 上传 ID
* @return 操作结果
*/
@PostMapping("/media/delete")
@SaCheckPermission(value = {
"/api/v1/agent/session/query", "/api/v1/agent/save"
}, mode = SaMode.OR)
public Result<Void> deleteMedia(@JsonBody(value = "uploadId", required = true) String uploadId) {
agentMediaService.deleteUpload(uploadId, SaTokenUtil.getLoginAccount());
return Result.ok();
}
/**
* 通过鉴权代理读取 Agent 私有聊天图片。
*
* @param reference 稳定图片引用
* @return 图片响应
*/
@GetMapping("/media/content")
@SaCheckPermission(value = {
"/api/v1/agent/session/query", "/api/v1/agent/save"
}, mode = SaMode.OR)
@LogReporterDisabled
public ResponseEntity<byte[]> mediaContent(@RequestParam("reference") String reference) {
AgentMediaResource resource = agentMediaService.load(reference, SaTokenUtil.getLoginAccount());
return ResponseEntity.ok()
.header(HttpHeaders.CACHE_CONTROL, "private, no-store")
.header(HttpHeaders.CONTENT_DISPOSITION, "inline")
.contentType(MediaType.parseMediaType(resource.mimeType()))
.contentLength(resource.bytes().length)
.body(resource.bytes());
}
/**
* 上传一份 Agent 聊天文档并异步触发轻量读取。
*
* @param file 文档文件
* @param mode 聊天模式
* @param agentId Agent ID
* @param sessionId 会话 ID
* @param uploadId 客户端生成的幂等上传 ID
* @return 上传与读取状态
*/
@PostMapping(value = "/media/document/upload", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@SaCheckPermission(value = {
"/api/v1/agent/session/query", "/api/v1/agent/save"
}, mode = SaMode.OR)
public Result<AgentDocumentUploadView> uploadDocument(@RequestParam("file") MultipartFile file,
@RequestParam("mode") String mode,
@RequestParam("agentId") String agentId,
@RequestParam("sessionId") String sessionId,
@RequestParam(value = "uploadId", required = false)
String uploadId) {
return Result.ok(agentDocumentService.upload(
file, mode, agentId, sessionId, uploadId, SaTokenUtil.getLoginAccount()));
}
/**
* 查询当前账号一个上传文档的读取状态。
*
* @param uploadId 上传 ID
* @return 最新状态
*/
@GetMapping("/media/document/status")
@SaCheckPermission(value = {
"/api/v1/agent/session/query", "/api/v1/agent/save"
}, mode = SaMode.OR)
public Result<AgentDocumentUploadView> documentStatus(@RequestParam("uploadId") String uploadId) {
return Result.ok(agentDocumentService.status(uploadId, SaTokenUtil.getLoginAccount()));
}
/**
* 重试一次明确失败的文档读取。
*
* @param uploadId 上传 ID
* @return 重试后的状态
*/
@PostMapping("/media/document/retry")
@SaCheckPermission(value = {
"/api/v1/agent/session/query", "/api/v1/agent/save"
}, mode = SaMode.OR)
public Result<AgentDocumentUploadView> retryDocument(
@JsonBody(value = "uploadId", required = true) String uploadId) {
return Result.ok(agentDocumentService.retry(uploadId, SaTokenUtil.getLoginAccount()));
}
/**
* 删除当前账号尚未发送的临时文档。
*
* @param uploadId 上传 ID
* @return 操作结果
*/
@PostMapping("/media/document/delete")
@SaCheckPermission(value = {
"/api/v1/agent/session/query", "/api/v1/agent/save"
}, mode = SaMode.OR)
public Result<Void> deleteDocument(
@JsonBody(value = "uploadId", required = true) String uploadId) {
agentDocumentService.deleteUpload(uploadId, SaTokenUtil.getLoginAccount());
return Result.ok();
}
/**
* 通过鉴权代理流式下载 Agent 私有聊天文档。
*
* @param reference 稳定文档引用
* @return 文档流
*/
@GetMapping("/media/document/content")
@SaCheckPermission(value = {
"/api/v1/agent/session/query", "/api/v1/agent/save"
}, mode = SaMode.OR)
@LogReporterDisabled
public ResponseEntity<StreamingResponseBody> documentContent(
@RequestParam("reference") String reference) {
AgentDocumentResource resource = agentDocumentService.load(
reference, SaTokenUtil.getLoginAccount());
StreamingResponseBody body = output -> {
try (var input = resource.inputStream()) {
input.transferTo(output);
}
};
ContentDisposition disposition = ContentDisposition.attachment()
.filename(resource.name(), StandardCharsets.UTF_8)
.build();
return ResponseEntity.ok()
.header(HttpHeaders.CACHE_CONTROL, "private, no-store")
.header(HttpHeaders.CONTENT_DISPOSITION, disposition.toString())
.contentType(MediaType.parseMediaType(resource.mimeType()))
.contentLength(resource.size())
.body(body);
}
/**
* 为输入框预分配稳定会话 ID。
*
* @param mode 聊天模式
* @return 会话信息
*/
@PostMapping("/composer/session")
@SaCheckPermission(value = {
"/api/v1/agent/session/query", "/api/v1/agent/save"
}, mode = SaMode.OR)
public Result<AgentComposerSession> allocateComposerSession(
@JsonBody(value = "mode", required = true) String mode) {
return Result.ok(agentComposerDraftService.allocateSession(mode));
}
/**
* 保存 Agent 输入草稿。
*
* @param draft 输入草稿
* @return 保存后的草稿
*/
@PostMapping("/composer/draft/persist")
@SaCheckPermission(value = {
"/api/v1/agent/session/query", "/api/v1/agent/save"
}, mode = SaMode.OR)
public Result<AgentComposerDraft> saveComposerDraft(@JsonBody AgentComposerDraft draft) {
return Result.ok(agentComposerDraftService.save(draft, SaTokenUtil.getLoginAccount()));
}
/**
* 获取当前会话或最近未发送会话的输入草稿。
*
* @param mode 聊天模式
* @param agentId Agent ID
* @param sessionId 会话 ID可为空
* @return 输入草稿
*/
@GetMapping("/composer/draft")
@SaCheckPermission(value = {
"/api/v1/agent/session/query", "/api/v1/agent/save"
}, mode = SaMode.OR)
public Result<AgentComposerDraft> getComposerDraft(@RequestParam("mode") String mode,
@RequestParam("agentId") String agentId,
@RequestParam(value = "sessionId", required = false) String sessionId) {
return Result.ok(agentComposerDraftService.get(mode, agentId, sessionId, SaTokenUtil.getLoginAccount())
.orElse(null));
}
/**
* 删除已发送或主动清空的输入草稿。
*
* @param mode 聊天模式
* @param agentId Agent ID
* @param sessionId 会话 ID
* @param imageUploadIds 调用方仍持有的上传 ID
* @param documentUploadIds 调用方仍持有的文档上传 ID
* @param deleteUploads 是否同时删除临时附件
* @return 操作结果
*/
@PostMapping("/composer/draft/delete")
@SaCheckPermission(value = {
"/api/v1/agent/session/query", "/api/v1/agent/save"
}, mode = SaMode.OR)
public Result<Void> deleteComposerDraft(@JsonBody(value = "mode", required = true) String mode,
@JsonBody(value = "agentId", required = true) String agentId,
@JsonBody(value = "sessionId", required = true) String sessionId,
@JsonBody(value = "imageUploadIds") List<String> imageUploadIds,
@JsonBody(value = "documentUploadIds") List<String> documentUploadIds,
@JsonBody(value = "deleteUploads") Boolean deleteUploads) {
agentComposerDraftService.delete(mode, agentId, sessionId, imageUploadIds, documentUploadIds,
!Boolean.FALSE.equals(deleteUploads), SaTokenUtil.getLoginAccount());
return Result.ok();
}
/**
* 清理 Agent 草稿试运行会话。
*
* @param sessionId 草稿试运行会话 ID
* @return 操作结果
*/
@PostMapping("/chat/draft/clear")
@SaCheckPermission("/api/v1/agent/save")
public Result<Void> clearDraftSession(@JsonBody(value = "sessionId", required = true) String sessionId) {
agentRunService.clearDraftSession(sessionId);
return Result.ok();
}
/**
* 批准工具执行。
*
* @param requestId 请求 ID
* @param resumeToken 恢复令牌
* @return 操作结果
*/
@PostMapping("/run/approve")
@SaCheckPermission(value = {
"/api/v1/agent/session/query", "/api/v1/agent/save"
}, mode = SaMode.OR)
public Result<Void> approve(@JsonBody("requestId") String requestId,
@JsonBody(value = "resumeToken", required = true) String resumeToken) {
agentRunService.approve(requestId, resumeToken);
return Result.ok();
}
/**
* 拒绝工具执行。
*
* @param requestId 请求 ID
* @param resumeToken 恢复令牌
* @param reason 拒绝原因
* @return 操作结果
*/
@PostMapping("/run/reject")
@SaCheckPermission(value = {
"/api/v1/agent/session/query", "/api/v1/agent/save"
}, mode = SaMode.OR)
public Result<Void> reject(@JsonBody("requestId") String requestId,
@JsonBody(value = "resumeToken", required = true) String resumeToken,
@JsonBody("reason") String reason) {
agentRunService.reject(requestId, resumeToken, reason);
return Result.ok();
}
/**
* 更新 Agent 工具绑定。
*
* @param agentId Agent ID
* @param bindings 工具绑定
* @return 保存后的启用绑定
*/
@PostMapping("/toolBinding/update")
@SaCheckPermission("/api/v1/agent/save")
public Result<List<AgentToolBinding>> updateToolBinding(@JsonBody(value = "agentId", required = true) BigInteger agentId,
@JsonBody("bindings") List<AgentToolBinding> bindings) {
return Result.ok(agentToolBindingService.replaceBindings(agentId, bindings));
}
/**
* 更新 Agent 知识库绑定。
*
* @param agentId Agent ID
* @param bindings 知识库绑定
* @return 保存后的启用绑定
*/
@PostMapping("/knowledgeBinding/update")
@SaCheckPermission("/api/v1/agent/save")
public Result<List<AgentKnowledgeBinding>> updateKnowledgeBinding(@JsonBody(value = "agentId", required = true) BigInteger agentId,
@JsonBody("bindings") List<AgentKnowledgeBinding> bindings) {
return Result.ok(agentKnowledgeBindingService.replaceBindings(agentId, bindings));
}
/**
* 原子替换 Agent 的全部 Skill 草稿绑定。
*
* @param request 白名单 Skill 引用请求
* @return 服务端生成的安全 Skill 摘要
*/
@PostMapping("/skillBinding/update")
@SaCheckPermission("/api/v1/agent/save")
public Result<List<AgentDetailView.SkillBindingView>> updateSkillBinding(
@JsonBody(required = true, skipConvertError = false) AgentSkillBindingUpdateRequest request) {
if (request == null || request.getAgentId() == null) {
throw new BusinessException("Agent ID 不能为空");
}
List<tech.easyflow.agent.entity.AgentSkillBinding> bindings = request.getBindings() == null
? List.of()
: request.getBindings().stream().map(AgentSkillBindingUpdateRequest.Binding::toEntity).toList();
return Result.ok(agentSkillBindingService.replaceBindings(request.getAgentId(), bindings)
.stream().map(AgentDetailView.SkillBindingView::from).toList());
}
/**
* 提交发布审批。
*
* @param id Agent ID
* @return 审批实例 ID
*/
@PostMapping("/submitPublishApproval")
@SaCheckPermission("/api/v1/agent/save")
public Result<BigInteger> submitPublishApproval(@JsonBody("id") BigInteger id) {
return buildApprovalActionResult(agentPublishAppService.submitPublishApproval(id), "已提交发布审批", "已直接发布");
}
/**
* 提交下线审批。
*
* @param id Agent ID
* @return 审批实例 ID
*/
@PostMapping("/submitOfflineApproval")
@SaCheckPermission("/api/v1/agent/save")
public Result<BigInteger> submitOfflineApproval(@JsonBody("id") BigInteger id) {
return buildApprovalActionResult(agentPublishAppService.submitOfflineApproval(id), "已提交下线审批", "已直接下线");
}
/**
* 提交删除审批。
*
* @param id Agent ID
* @return 审批实例 ID
*/
@PostMapping("/submitDeleteApproval")
@SaCheckPermission("/api/v1/agent/remove")
public Result<BigInteger> submitDeleteApproval(@JsonBody("id") BigInteger id) {
return buildApprovalActionResult(agentPublishAppService.submitDeleteApproval(id), "已提交删除审批", "已直接删除");
}
@Override
public Result<?> remove(Serializable id) {
throw new BusinessException("Agent 仅支持通过生命周期审批删除");
}
@Override
public Result<?> removeBatch(Collection<Serializable> ids) {
throw new BusinessException("Agent 仅支持通过生命周期审批删除");
}
/**
* 查询 Agent 分页。
*
* @param page 分页参数
* @param queryWrapper 查询条件
* @return Agent 分页
*/
@Override
protected Page<Agent> queryPage(Page<Agent> page, QueryWrapper queryWrapper) {
agentVisibilityQueryHelper.applyReadableAccess(queryWrapper);
applyPublishedOnlyFilter(queryWrapper);
Page<Agent> result = service.page(page, queryWrapper);
if (isPublishedOnlyRequest()) {
result.setRecords(result.getRecords().stream().map(agent -> service.fromSnapshot(agent.getPublishedSnapshotJson())).toList());
}
result.getRecords().forEach(this::sanitizeListItem);
agentApprovalStateService.fillAgentApprovalState(result.getRecords());
aiResourceCreatorNameSupport.fillAgentCreatorNames(result.getRecords());
return result;
}
/**
* 清理列表无需返回的配置和发布快照,避免敏感运行配置进入浏览器。
*
* @param agent Agent 列表项
*/
private void sanitizeListItem(Agent agent) {
if (agent == null) {
return;
}
agent.setModelConfigJson(Collections.emptyMap());
agent.setGenerationConfigJson(Collections.emptyMap());
agent.setPromptConfigJson(Collections.emptyMap());
agent.setMemoryConfigJson(Collections.emptyMap());
agent.setExecutionConfigJson(Collections.emptyMap());
agent.setInteractionConfigJson(Collections.emptyMap());
agent.setPublishedSnapshotJson(Collections.emptyMap());
agent.setToolBindings(null);
agent.setKnowledgeBindings(null);
agent.setSkillBindings(null);
}
/**
* 为仅发布查询追加发布状态条件。
*
* @param queryWrapper Agent 查询条件
*/
private void applyPublishedOnlyFilter(QueryWrapper queryWrapper) {
if (isPublishedOnlyRequest()) {
queryWrapper.eq("publish_status", PublishStatus.PUBLISHED.getCode());
}
}
/**
* 判断当前请求是否只查询已发布 Agent。
*
* @return 是否仅查询已发布 Agent
*/
private boolean isPublishedOnlyRequest() {
HttpServletRequest request = currentRequest();
if (request == null) {
return false;
}
return "true".equalsIgnoreCase(request.getParameter("publishedOnly"));
}
/**
* 获取当前 HTTP 请求。
*
* @return 当前请求,不在 Web 请求上下文中时返回 null
*/
private HttpServletRequest currentRequest() {
ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
if (attributes == null) {
return null;
}
return attributes.getRequest();
}
/**
* 将审批执行结果转换为统一响应。
*
* @param actionResult 审批动作结果
* @param approvalMessage 进入审批时的提示
* @param directMessage 直接执行时的提示
* @return 审批实例响应
*/
private Result<BigInteger> buildApprovalActionResult(ApprovalActionResult actionResult,
String approvalMessage,
String directMessage) {
return Result.ok(actionResult.isApprovalRequired() ? approvalMessage : directMessage, actionResult.getInstanceId());
}
}

View File

@@ -0,0 +1,114 @@
package tech.easyflow.admin.controller.agent;
import tech.easyflow.agent.entity.Agent;
import tech.easyflow.agent.entity.AgentKnowledgeBinding;
import tech.easyflow.agent.entity.AgentSkillBinding;
import tech.easyflow.agent.entity.AgentToolBinding;
import java.math.BigInteger;
import java.util.Date;
import java.util.List;
import java.util.Map;
/**
* 管理端 Agent 草稿安全详情。
*
* <p>该视图明确排除发布快照以及各绑定的内部资源快照。</p>
*/
public record AgentDetailView(
BigInteger id,
BigInteger deptId,
String name,
String description,
String avatar,
BigInteger categoryId,
BigInteger modelId,
Map<String, Object> modelConfigJson,
Map<String, Object> generationConfigJson,
Map<String, Object> promptConfigJson,
Map<String, Object> memoryConfigJson,
Map<String, Object> executionConfigJson,
Map<String, Object> interactionConfigJson,
Integer status,
String visibilityScope,
String publishStatus,
BigInteger currentApprovalInstanceId,
Date publishedAt,
BigInteger publishedBy,
Date created,
BigInteger createdBy,
Date modified,
BigInteger modifiedBy,
Boolean approvalPending,
String currentApprovalActionType,
String displayPublishStatus,
String createdByName,
List<ToolBindingView> toolBindings,
List<KnowledgeBindingView> knowledgeBindings,
List<SkillBindingView> skillBindings) {
/**
* 从领域实体构造安全详情。
*
* @param agent Agent 领域实体
* @return 安全详情
*/
public static AgentDetailView from(Agent agent) {
return new AgentDetailView(agent.getId(), agent.getDeptId(), agent.getName(), agent.getDescription(),
agent.getAvatar(), agent.getCategoryId(), agent.getModelId(), agent.getModelConfigJson(),
agent.getGenerationConfigJson(), agent.getPromptConfigJson(), agent.getMemoryConfigJson(),
agent.getExecutionConfigJson(), agent.getInteractionConfigJson(), agent.getStatus(),
agent.getVisibilityScope(), agent.getPublishStatus(), agent.getCurrentApprovalInstanceId(),
agent.getPublishedAt(), agent.getPublishedBy(), agent.getCreated(), agent.getCreatedBy(),
agent.getModified(), agent.getModifiedBy(), agent.getApprovalPending(),
agent.getCurrentApprovalActionType(), agent.getDisplayPublishStatus(), agent.getCreatedByName(),
mapTools(agent.getToolBindings()), mapKnowledges(agent.getKnowledgeBindings()),
mapSkills(agent.getSkillBindings()));
}
private static List<ToolBindingView> mapTools(List<AgentToolBinding> bindings) {
return bindings == null ? List.of() : bindings.stream().map(ToolBindingView::from).toList();
}
private static List<KnowledgeBindingView> mapKnowledges(List<AgentKnowledgeBinding> bindings) {
return bindings == null ? List.of() : bindings.stream().map(KnowledgeBindingView::from).toList();
}
private static List<SkillBindingView> mapSkills(List<AgentSkillBinding> bindings) {
return bindings == null ? List.of() : bindings.stream().map(SkillBindingView::from).toList();
}
/** Agent 直接 Tool 草稿绑定。 */
public record ToolBindingView(BigInteger id, String toolType, BigInteger targetId, String toolName,
Boolean enabled, Boolean hitlEnabled, Map<String, Object> hitlConfigJson,
Map<String, Object> optionsJson, Integer sortNo,
Map<String, Object> resourceSummary) {
/** @param value 实体 @return 安全绑定 */
static ToolBindingView from(AgentToolBinding value) {
return new ToolBindingView(value.getId(), value.getToolType(), value.getTargetId(), value.getToolName(),
value.getEnabled(), value.getHitlEnabled(), value.getHitlConfigJson(), value.getOptionsJson(),
value.getSortNo(), value.getResourceSummary());
}
}
/** Agent 知识库草稿绑定。 */
public record KnowledgeBindingView(BigInteger id, BigInteger knowledgeId, String retrievalMode,
Boolean enabled, Map<String, Object> optionsJson, Integer sortNo,
Map<String, Object> resourceSummary) {
/** @param value 实体 @return 安全绑定 */
static KnowledgeBindingView from(AgentKnowledgeBinding value) {
return new KnowledgeBindingView(value.getId(), value.getKnowledgeId(), value.getRetrievalMode(),
value.getEnabled(), value.getOptionsJson(), value.getSortNo(), value.getResourceSummary());
}
}
/** Agent Skill 草稿绑定。 */
public record SkillBindingView(BigInteger id, BigInteger skillId, Integer sortNo,
Map<String, Object> resourceSummary) {
/** @param value 实体 @return 安全绑定 */
static SkillBindingView from(AgentSkillBinding value) {
return new SkillBindingView(value.getId(), value.getSkillId(), value.getSortNo(),
value.getResourceSummary());
}
}
}

View File

@@ -0,0 +1,109 @@
package tech.easyflow.admin.controller.agent;
import tech.easyflow.agent.entity.Agent;
import tech.easyflow.agent.entity.AgentKnowledgeBinding;
import tech.easyflow.agent.entity.AgentSkillBinding;
import tech.easyflow.agent.entity.AgentToolBinding;
import java.util.List;
/**
* Agent 设计器原子保存请求。
*
* <p>绑定变更标记由设计器基于加载后的稳定业务字段计算。服务端仍会执行权限、状态与幂等比较,
* 标记为未变化的绑定不会进入查询、外部资源校验或整组重写流程。</p>
*/
public class AgentDraftSaveRequest {
private Agent agent;
private List<AgentToolBinding> toolBindings;
private boolean replaceToolBindings;
private List<AgentKnowledgeBinding> knowledgeBindings;
private boolean replaceKnowledgeBindings;
private List<AgentSkillBindingUpdateRequest.Binding> skillBindings;
private boolean replaceSkillBindings;
/** 创建空请求。 */
public AgentDraftSaveRequest() {
}
/** @return Agent 草稿 */
public Agent getAgent() {
return agent;
}
/** @param agent Agent 草稿 */
public void setAgent(Agent agent) {
this.agent = agent;
}
/** @return 工具绑定 */
public List<AgentToolBinding> getToolBindings() {
return toolBindings;
}
/** @param toolBindings 工具绑定 */
public void setToolBindings(List<AgentToolBinding> toolBindings) {
this.toolBindings = toolBindings;
}
/** @return 是否替换工具绑定 */
public boolean isReplaceToolBindings() {
return replaceToolBindings;
}
/** @param replaceToolBindings 是否替换工具绑定 */
public void setReplaceToolBindings(boolean replaceToolBindings) {
this.replaceToolBindings = replaceToolBindings;
}
/** @return 知识库绑定 */
public List<AgentKnowledgeBinding> getKnowledgeBindings() {
return knowledgeBindings;
}
/** @param knowledgeBindings 知识库绑定 */
public void setKnowledgeBindings(List<AgentKnowledgeBinding> knowledgeBindings) {
this.knowledgeBindings = knowledgeBindings;
}
/** @return 是否替换知识库绑定 */
public boolean isReplaceKnowledgeBindings() {
return replaceKnowledgeBindings;
}
/** @param replaceKnowledgeBindings 是否替换知识库绑定 */
public void setReplaceKnowledgeBindings(boolean replaceKnowledgeBindings) {
this.replaceKnowledgeBindings = replaceKnowledgeBindings;
}
/** @return Skill 绑定 */
public List<AgentSkillBindingUpdateRequest.Binding> getSkillBindings() {
return skillBindings;
}
/** @param skillBindings Skill 绑定 */
public void setSkillBindings(List<AgentSkillBindingUpdateRequest.Binding> skillBindings) {
this.skillBindings = skillBindings;
}
/** @return 是否替换 Skill 绑定 */
public boolean isReplaceSkillBindings() {
return replaceSkillBindings;
}
/** @param replaceSkillBindings 是否替换 Skill 绑定 */
public void setReplaceSkillBindings(boolean replaceSkillBindings) {
this.replaceSkillBindings = replaceSkillBindings;
}
/**
* 将 Skill 白名单引用转换为领域绑定。
*
* @return 最小 Skill 绑定列表
*/
public List<AgentSkillBinding> toSkillBindings() {
return skillBindings == null
? List.of() : skillBindings.stream().map(AgentSkillBindingUpdateRequest.Binding::toEntity).toList();
}
}

View File

@@ -0,0 +1,176 @@
package tech.easyflow.admin.controller.agent;
import cn.dev33.satoken.annotation.SaCheckPermission;
import com.mybatisflex.core.keygen.impl.SnowFlakeIDKeyGenerator;
import org.springframework.web.bind.annotation.*;
import tech.easyflow.admin.dto.chatworkspace.ChatWorkspaceConversationView;
import tech.easyflow.admin.dto.chatworkspace.ChatWorkspaceSessionDetailView;
import tech.easyflow.admin.dto.chatworkspace.ChatWorkspaceSessionPage;
import tech.easyflow.admin.service.agent.AgentSessionService;
import tech.easyflow.agent.service.AgentOptionQueryService;
import tech.easyflow.agent.vo.AgentOptionView;
import tech.easyflow.agent.vo.AgentResourceOptionsView;
import tech.easyflow.chatlog.domain.dto.ChatHistoryPage;
import tech.easyflow.chatlog.domain.query.ChatPageQuery;
import tech.easyflow.common.domain.Result;
import tech.easyflow.common.entity.LoginAccount;
import tech.easyflow.common.satoken.util.SaTokenUtil;
import tech.easyflow.common.web.exceptions.BusinessException;
import tech.easyflow.common.web.jsonbody.JsonBody;
import java.math.BigInteger;
import java.util.List;
/**
* Agent 管理端会话控制器。
*/
@RestController
@RequestMapping("/api/v1/agent/session")
@SaCheckPermission("/api/v1/agent/session/query")
public class AgentSessionController {
private final AgentSessionService agentSessionService;
private final AgentOptionQueryService agentOptionQueryService;
/**
* 创建 Agent 管理端会话控制器。
*
* @param agentSessionService Agent 会话服务
* @param agentOptionQueryService Agent 安全选项服务
*/
public AgentSessionController(AgentSessionService agentSessionService,
AgentOptionQueryService agentOptionQueryService) {
this.agentSessionService = agentSessionService;
this.agentOptionQueryService = agentOptionQueryService;
}
/**
* 查询正式聊天可使用的已发布 Agent。
*
* @return Agent 安全选项
*/
@GetMapping("/options")
public Result<List<AgentOptionView>> options() {
return Result.ok(agentOptionQueryService.listAgentOptions(true));
}
/**
* 查询正式聊天可附加的知识库。
*
* @return 知识库安全选项
*/
@GetMapping("/knowledgeOptions")
public Result<List<AgentResourceOptionsView.ResourceOption>> knowledgeOptions() {
return Result.ok(agentOptionQueryService.listKnowledgeOptions());
}
/**
* 生成 Agent 会话 ID。
*
* @return 会话 ID 字符串
*/
@GetMapping("/generateId")
public Result<String> generateId() {
long nextId = new SnowFlakeIDKeyGenerator().nextId();
return Result.ok(String.valueOf(nextId));
}
/**
* 查询 Agent 会话分页。
*
* @param agentId Agent ID可为空
* @param query 分页参数
* @return 会话分页
*/
@GetMapping("/list")
public Result<ChatWorkspaceSessionPage> list(BigInteger agentId, ChatPageQuery query) {
return Result.ok(agentSessionService.queryCurrentUserSessions(currentAccount(), agentId, query));
}
/**
* 查询 Agent 会话详情。
*
* @param sessionId 会话 ID
* @return 会话详情
*/
@GetMapping("/{sessionId}")
public Result<ChatWorkspaceSessionDetailView> detail(@PathVariable BigInteger sessionId) {
return Result.ok(agentSessionService.getCurrentUserSession(currentAccount(), sessionId));
}
/**
* 查询 Agent 会话消息。
*
* @param sessionId 会话 ID
* @param query 分页参数
* @return 消息分页
*/
@GetMapping("/{sessionId}/messages")
public Result<ChatHistoryPage> messages(@PathVariable BigInteger sessionId, ChatPageQuery query) {
return Result.ok(agentSessionService.queryCurrentUserMessages(currentAccount(), sessionId, query));
}
/**
* 查询 Agent 完整会话。
*
* @param sessionId 会话 ID
* @return 完整会话
*/
@GetMapping("/{sessionId}/conversation")
public Result<ChatWorkspaceConversationView> conversation(@PathVariable BigInteger sessionId) {
return Result.ok(agentSessionService.getCurrentUserConversation(currentAccount(), sessionId));
}
/**
* 重命名 Agent 会话。
*
* @param sessionId 会话 ID
* @param title 新标题
* @return 操作结果
*/
@PostMapping("/{sessionId}/rename")
public Result<Void> rename(@PathVariable BigInteger sessionId,
@JsonBody(value = "title", required = true) String title) {
agentSessionService.renameCurrentUserSession(currentAccount(), sessionId, title);
return Result.ok();
}
/**
* 保存 Agent 会话临时知识库。
*
* @param sessionId 会话 ID
* @param knowledgeIds 临时知识库 ID
* @return 操作结果
*/
@PostMapping("/{sessionId}/extraKnowledges")
public Result<ChatWorkspaceSessionDetailView> saveExtraKnowledges(@PathVariable BigInteger sessionId,
@JsonBody(value = "knowledgeIds") List<BigInteger> knowledgeIds) {
return Result.ok(agentSessionService.saveCurrentUserExtraKnowledges(currentAccount(), sessionId, knowledgeIds));
}
/**
* 删除 Agent 会话。
*
* @param sessionId 会话 ID
* @return 操作结果
*/
@PostMapping("/{sessionId}/delete")
public Result<Void> delete(@PathVariable BigInteger sessionId) {
agentSessionService.deleteCurrentUserSession(currentAccount(), sessionId);
return Result.ok();
}
/**
* 获取当前登录账号。
*
* @return 当前登录账号
* @throws BusinessException 登录信息失效时抛出
*/
private LoginAccount currentAccount() {
LoginAccount account = SaTokenUtil.getLoginAccount();
if (account == null || account.getId() == null || account.getTenantId() == null) {
throw new BusinessException("当前登录状态失效,请重新登录后再试");
}
return account;
}
}

View File

@@ -0,0 +1,84 @@
package tech.easyflow.admin.controller.agent;
import tech.easyflow.agent.entity.AgentSkillBinding;
import java.math.BigInteger;
import java.util.List;
/**
* Agent Skill 整组替换请求。
*
* <p>使用标准 JavaBean 以兼容 {@code @JsonBody} 的 Fastjson 1 嵌套列表转换。</p>
*/
public class AgentSkillBindingUpdateRequest {
private BigInteger agentId;
private List<Binding> bindings;
/** 创建空请求。 */
public AgentSkillBindingUpdateRequest() {
}
/**
* 创建 Agent Skill 绑定请求。
*
* @param agentId Agent ID
* @param bindings Skill 引用
*/
public AgentSkillBindingUpdateRequest(BigInteger agentId, List<Binding> bindings) {
this.agentId = agentId;
this.bindings = bindings;
}
/** @return Agent ID */
public BigInteger getAgentId() { return agentId; }
/** @param agentId Agent ID */
public void setAgentId(BigInteger agentId) { this.agentId = agentId; }
/** @return Skill 引用 */
public List<Binding> getBindings() { return bindings; }
/** @param bindings Skill 引用 */
public void setBindings(List<Binding> bindings) { this.bindings = bindings; }
/** 客户端允许提交的最小 Skill 引用。 */
public static class Binding {
private BigInteger skillId;
private Integer sortNo;
/** 创建空绑定。 */
public Binding() {
}
/**
* 创建最小 Skill 绑定。
*
* @param skillId Skill ID
* @param sortNo 排序号
*/
public Binding(BigInteger skillId, Integer sortNo) {
this.skillId = skillId;
this.sortNo = sortNo;
}
/** @return Skill ID */
public BigInteger getSkillId() { return skillId; }
/** @param skillId Skill ID */
public void setSkillId(BigInteger skillId) { this.skillId = skillId; }
/** @return 排序号 */
public Integer getSortNo() { return sortNo; }
/** @param sortNo 排序号 */
public void setSortNo(Integer sortNo) { this.sortNo = sortNo; }
/**
* 转换为不含任何服务端快照的领域引用。
*
* @return 最小 Skill 绑定
*/
public AgentSkillBinding toEntity() {
AgentSkillBinding value = new AgentSkillBinding();
value.setSkillId(skillId);
value.setSortNo(sortNo);
return value;
}
}
}

View File

@@ -18,7 +18,7 @@ import org.springframework.web.context.request.ServletRequestAttributes;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile; import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import tech.easyflow.admin.controller.ai.support.AiResourceCreatorNameSupport; import tech.easyflow.admin.controller.ai.support.BotResourceCreatorNameSupport;
import tech.easyflow.admin.service.ai.ChatWorkspaceService; import tech.easyflow.admin.service.ai.ChatWorkspaceService;
import tech.easyflow.ai.chattime.availability.ChatTimeToolAvailabilityContext; import tech.easyflow.ai.chattime.availability.ChatTimeToolAvailabilityContext;
import tech.easyflow.ai.easyagents.listener.PromptChoreChatStreamListener; import tech.easyflow.ai.easyagents.listener.PromptChoreChatStreamListener;
@@ -82,9 +82,9 @@ public class BotController extends BaseCurdController<BotService, Bot> {
@Resource @Resource
private ChatRoundOperateService chatRoundOperateService; private ChatRoundOperateService chatRoundOperateService;
@Resource @Resource
private AiResourceApprovalStateService aiResourceApprovalStateService; private BotApprovalStateService botApprovalStateService;
@Resource @Resource
private AiResourceCreatorNameSupport aiResourceCreatorNameSupport; private BotResourceCreatorNameSupport botResourceCreatorNameSupport;
@Resource @Resource
private ChatWorkspaceService chatWorkspaceService; private ChatWorkspaceService chatWorkspaceService;
@@ -97,6 +97,16 @@ public class BotController extends BaseCurdController<BotService, Bot> {
this.botMessageService = botMessageService; this.botMessageService = botMessageService;
} }
/**
* 获取智能体列表关键字搜索字段。
*
* @return 标题和描述属性
*/
@Override
protected String[] getKeywordSearchProperties() {
return new String[]{"title", "description"};
}
@Resource @Resource
private BotPluginService botPluginService; private BotPluginService botPluginService;
@@ -240,7 +250,7 @@ public class BotController extends BaseCurdController<BotService, Bot> {
bot = botService.toPublishedView(rawBot); bot = botService.toPublishedView(rawBot);
} }
if (StpUtil.isLogin()) { if (StpUtil.isLogin()) {
aiResourceApprovalStateService.fillBotApprovalState(bot); botApprovalStateService.fillApprovalState(bot);
} }
return Result.ok(bot); return Result.ok(bot);
} }
@@ -275,7 +285,7 @@ public class BotController extends BaseCurdController<BotService, Bot> {
if (data.getModelId() == null) { if (data.getModelId() == null) {
if (StpUtil.isLogin()) { if (StpUtil.isLogin()) {
aiResourceApprovalStateService.fillBotApprovalState(data); botApprovalStateService.fillApprovalState(data);
} }
return Result.ok(data); return Result.ok(data);
} }
@@ -286,7 +296,7 @@ public class BotController extends BaseCurdController<BotService, Bot> {
if (llm == null) { if (llm == null) {
data.setModelId(null); data.setModelId(null);
if (StpUtil.isLogin()) { if (StpUtil.isLogin()) {
aiResourceApprovalStateService.fillBotApprovalState(data); botApprovalStateService.fillApprovalState(data);
} }
return Result.ok(data); return Result.ok(data);
} }
@@ -302,21 +312,43 @@ public class BotController extends BaseCurdController<BotService, Bot> {
} }
if (StpUtil.isLogin()) { if (StpUtil.isLogin()) {
aiResourceApprovalStateService.fillBotApprovalState(data); botApprovalStateService.fillApprovalState(data);
} }
return Result.ok(data); return Result.ok(data);
} }
/**
* 提交聊天助手发布审批。
*
* @param id 助手 ID
* @param applicationReason 审批说明
* @return 审批实例 ID
*/
@PostMapping("/submitPublishApproval") @PostMapping("/submitPublishApproval")
@SaCheckPermission("/api/v1/bot/save") @SaCheckPermission("/api/v1/bot/save")
public Result<BigInteger> submitPublishApproval(@JsonBody("id") BigInteger id) { public Result<BigInteger> submitPublishApproval(
@JsonBody("id") BigInteger id,
@JsonBody("applicationReason") String applicationReason
) {
return buildApprovalActionResult( return buildApprovalActionResult(
botPublishAppService.submitPublishApproval(id), botPublishAppService.submitPublishApproval(id, applicationReason),
"已提交发布审批", "已提交发布审批",
"已直接发布" "已直接发布"
); );
} }
/**
* 预检聊天助手发布是否命中审批流。
*
* @param id 助手 ID
* @return 是否需要审批
*/
@GetMapping("/publishApprovalRequirement")
@SaCheckPermission("/api/v1/bot/save")
public Result<Boolean> publishApprovalRequirement(@RequestParam BigInteger id) {
return Result.ok(botPublishAppService.isPublishApprovalRequired(id));
}
@PostMapping("/submitOfflineApproval") @PostMapping("/submitOfflineApproval")
@SaCheckPermission("/api/v1/bot/save") @SaCheckPermission("/api/v1/bot/save")
public Result<BigInteger> submitOfflineApproval(@JsonBody("id") BigInteger id) { public Result<BigInteger> submitOfflineApproval(@JsonBody("id") BigInteger id) {
@@ -347,7 +379,7 @@ public class BotController extends BaseCurdController<BotService, Bot> {
if (isPublishedOnlyRequest()) { if (isPublishedOnlyRequest()) {
bots = bots.stream().map(botService::toPublishedView).toList(); bots = bots.stream().map(botService::toPublishedView).toList();
} }
aiResourceApprovalStateService.fillBotApprovalState(bots); botApprovalStateService.fillApprovalState(bots);
return Result.ok(bots); return Result.ok(bots);
} }
@@ -359,8 +391,8 @@ public class BotController extends BaseCurdController<BotService, Bot> {
if (isPublishedOnlyRequest()) { if (isPublishedOnlyRequest()) {
result.setRecords(result.getRecords().stream().map(botService::toPublishedView).toList()); result.setRecords(result.getRecords().stream().map(botService::toPublishedView).toList());
} }
aiResourceApprovalStateService.fillBotApprovalState(result.getRecords()); botApprovalStateService.fillApprovalState(result.getRecords());
aiResourceCreatorNameSupport.fillBotCreatorNames(result.getRecords()); botResourceCreatorNameSupport.fillCreatorNames(result.getRecords());
return result; return result;
} }

View File

@@ -0,0 +1,64 @@
package tech.easyflow.admin.controller.ai;
import cn.dev33.satoken.annotation.SaCheckPermission;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import tech.easyflow.ai.entity.PluginItem;
import tech.easyflow.ai.service.BotPluginService;
import tech.easyflow.common.annotation.UsePermission;
import tech.easyflow.common.domain.Result;
import tech.easyflow.common.web.jsonbody.JsonBody;
import java.math.BigInteger;
import java.util.List;
/**
* 旧 Bot 插件工具绑定查询控制器。
*
* <p>保留历史接口地址,同时将 Bot 表依赖限制在 Bot 专属代码中。</p>
*/
@RestController
@RequestMapping("/api/v1/pluginItem")
@UsePermission(moduleName = "/api/v1/plugin")
public class BotPluginItemController {
private final BotPluginService botPluginService;
/**
* 创建 Bot 插件工具绑定查询控制器。
*
* @param botPluginService Bot 插件绑定服务
*/
public BotPluginItemController(BotPluginService botPluginService) {
this.botPluginService = botPluginService;
}
/**
* 查询插件工具,并标记指定 Bot 已绑定的工具。
*
* @param pluginId 插件 ID
* @param botId Bot ID
* @return 插件工具列表
*/
@PostMapping("/toolsList")
@SaCheckPermission("/api/v1/plugin/query")
public Result<List<PluginItem>> searchPluginTools(
@JsonBody(value = "pluginId", required = true) BigInteger pluginId,
@JsonBody(value = "botId", required = false) BigInteger botId) {
return Result.ok(botPluginService.searchPluginTools(pluginId, botId));
}
/**
* 查询指定 Bot 已绑定的插件工具。
*
* @param botId Bot ID
* @return 已绑定插件工具列表
*/
@PostMapping("/tool/list")
@SaCheckPermission("/api/v1/plugin/query")
public Result<List<PluginItem>> getPluginTools(
@JsonBody(value = "botId", required = true) BigInteger botId) {
return Result.ok(botPluginService.getPluginTools(botId));
}
}

View File

@@ -1,10 +1,13 @@
package tech.easyflow.admin.controller.ai; package tech.easyflow.admin.controller.ai;
import cn.dev33.satoken.annotation.SaCheckPermission;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
import tech.easyflow.agent.service.AgentOptionQueryService;
import tech.easyflow.agent.vo.AgentOptionView;
import tech.easyflow.chatlog.domain.dto.ChatHistoryPage; import tech.easyflow.chatlog.domain.dto.ChatHistoryPage;
import tech.easyflow.chatlog.domain.dto.ChatMessageRecord; import tech.easyflow.chatlog.domain.dto.ChatMessageRecord;
import tech.easyflow.chatlog.domain.dto.ChatSessionPage; import tech.easyflow.chatlog.domain.dto.ChatSessionPage;
@@ -16,46 +19,133 @@ import tech.easyflow.common.domain.Result;
import tech.easyflow.common.entity.LoginAccount; import tech.easyflow.common.entity.LoginAccount;
import tech.easyflow.common.satoken.util.SaTokenUtil; import tech.easyflow.common.satoken.util.SaTokenUtil;
import tech.easyflow.common.web.jsonbody.JsonBody; import tech.easyflow.common.web.jsonbody.JsonBody;
import tech.easyflow.system.service.CategoryPermissionService;
import java.math.BigInteger; import java.math.BigInteger;
import java.util.List; import java.util.List;
@RestController @RestController
@RequestMapping("/api/v1/chatHistory") @RequestMapping("/api/v1/chatHistory")
@SaCheckPermission("/api/v1/chatHistory/query")
public class ChatHistoryController { public class ChatHistoryController {
private final ChatHistoryManageService chatHistoryManageService; private final ChatHistoryManageService chatHistoryManageService;
private final CategoryPermissionService categoryPermissionService;
private final AgentOptionQueryService agentOptionQueryService;
public ChatHistoryController(ChatHistoryManageService chatHistoryManageService) { /**
* 创建聊天历史控制器。
*
* @param chatHistoryManageService 聊天历史管理服务
* @param categoryPermissionService 账号权限服务
* @param agentOptionQueryService Agent 安全选项服务
*/
public ChatHistoryController(ChatHistoryManageService chatHistoryManageService,
CategoryPermissionService categoryPermissionService,
AgentOptionQueryService agentOptionQueryService) {
this.chatHistoryManageService = chatHistoryManageService; this.chatHistoryManageService = chatHistoryManageService;
this.categoryPermissionService = categoryPermissionService;
this.agentOptionQueryService = agentOptionQueryService;
} }
/**
* 查询聊天记录筛选可使用的 Agent。
*
* @return Agent 安全选项
*/
@GetMapping("/agentOptions")
public Result<List<AgentOptionView>> agentOptions() {
return Result.ok(agentOptionQueryService.listAgentOptions(false));
}
/**
* 分页查询当前账号可见的 Agent 会话。
*
* @param query 会话筛选条件
* @return 会话分页结果
*/
@GetMapping("/sessions") @GetMapping("/sessions")
public Result<ChatSessionPage> listSessions(ChatSessionFilterQuery query) { public Result<ChatSessionPage> listSessions(ChatSessionFilterQuery query) {
return Result.ok(chatHistoryManageService.queryAdminSessions(query)); LoginAccount account = SaTokenUtil.getLoginAccount();
return Result.ok(chatHistoryManageService.queryAdminSessions(
account.getId(),
categoryPermissionService.isSuperAdmin(account),
query
));
} }
/**
* 获取当前账号可见的 Agent 会话详情。
*
* @param sessionId 会话 ID
* @return 会话详情
*/
@GetMapping("/sessions/{sessionId}") @GetMapping("/sessions/{sessionId}")
public Result<ChatSessionSummary> getSession(@PathVariable BigInteger sessionId) { public Result<ChatSessionSummary> getSession(@PathVariable BigInteger sessionId) {
return Result.ok(chatHistoryManageService.getAdminSession(sessionId)); LoginAccount account = SaTokenUtil.getLoginAccount();
return Result.ok(chatHistoryManageService.getAdminSession(
account.getId(),
categoryPermissionService.isSuperAdmin(account),
sessionId
));
} }
/**
* 分页查询当前账号可见会话的消息。
*
* @param sessionId 会话 ID
* @param query 消息分页条件
* @return 消息分页结果
*/
@GetMapping("/sessions/{sessionId}/messages") @GetMapping("/sessions/{sessionId}/messages")
public Result<ChatHistoryPage> queryMessages(@PathVariable BigInteger sessionId, ChatPageQuery query) { public Result<ChatHistoryPage> queryMessages(@PathVariable BigInteger sessionId, ChatPageQuery query) {
return Result.ok(chatHistoryManageService.queryAdminMessages(sessionId, query)); LoginAccount account = SaTokenUtil.getLoginAccount();
return Result.ok(chatHistoryManageService.queryAdminMessages(
account.getId(),
categoryPermissionService.isSuperAdmin(account),
sessionId,
query
));
} }
/**
* 查询当前账号可见会话的答案版本。
*
* @param sessionId 会话 ID
* @param roundId 对话轮次 ID
* @return 答案版本列表
*/
@GetMapping("/sessions/{sessionId}/rounds/{roundId}/variants") @GetMapping("/sessions/{sessionId}/rounds/{roundId}/variants")
public Result<List<ChatMessageRecord>> listRoundVariants(@PathVariable BigInteger sessionId, public Result<List<ChatMessageRecord>> listRoundVariants(@PathVariable BigInteger sessionId,
@PathVariable BigInteger roundId) { @PathVariable BigInteger roundId) {
return Result.ok(chatHistoryManageService.listAdminRoundVariants(sessionId, roundId)); LoginAccount account = SaTokenUtil.getLoginAccount();
return Result.ok(chatHistoryManageService.listAdminRoundVariants(
account.getId(),
categoryPermissionService.isSuperAdmin(account),
sessionId,
roundId
));
} }
/**
* 选择当前账号可见会话的答案版本。
*
* @param sessionId 会话 ID
* @param roundId 对话轮次 ID
* @param variantIndex 目标版本索引
* @return 选中的答案记录
*/
@PostMapping("/sessions/{sessionId}/rounds/{roundId}/selectVariant") @PostMapping("/sessions/{sessionId}/rounds/{roundId}/selectVariant")
public Result<ChatMessageRecord> selectRoundVariant(@PathVariable BigInteger sessionId, public Result<ChatMessageRecord> selectRoundVariant(@PathVariable BigInteger sessionId,
@PathVariable BigInteger roundId, @PathVariable BigInteger roundId,
@JsonBody(value = "variantIndex", required = true) Integer variantIndex) { @JsonBody(value = "variantIndex", required = true) Integer variantIndex) {
LoginAccount account = SaTokenUtil.getLoginAccount(); LoginAccount account = SaTokenUtil.getLoginAccount();
return Result.ok(chatHistoryManageService.selectAdminRoundVariant(sessionId, roundId, variantIndex, account.getId())); return Result.ok(chatHistoryManageService.selectAdminRoundVariant(
account.getId(),
categoryPermissionService.isSuperAdmin(account),
sessionId,
roundId,
variantIndex
));
} }
} }

View File

@@ -1,22 +1,18 @@
package tech.easyflow.admin.controller.ai; package tech.easyflow.admin.controller.ai;
import cn.dev33.satoken.annotation.SaCheckPermission; import cn.dev33.satoken.annotation.SaCheckPermission;
import com.easyagents.core.model.embedding.EmbeddingModel;
import com.mybatisflex.core.paginate.Page; import com.mybatisflex.core.paginate.Page;
import com.mybatisflex.core.query.QueryWrapper;
import tech.easyflow.ai.dto.DocumentChunkContentUpdateRequest;
import tech.easyflow.ai.dto.DocumentChunkSyncRetryRequest;
import tech.easyflow.ai.dto.DocumentChunkSyncStatus;
import tech.easyflow.ai.dto.DocumentChunkSyncStatusRequest;
import tech.easyflow.ai.entity.DocumentChunk; import tech.easyflow.ai.entity.DocumentChunk;
import tech.easyflow.ai.entity.DocumentCollection;
import tech.easyflow.ai.entity.Model;
import tech.easyflow.ai.service.DocumentChunkService; import tech.easyflow.ai.service.DocumentChunkService;
import tech.easyflow.ai.service.DocumentCollectionService;
import tech.easyflow.ai.service.ModelService;
import tech.easyflow.common.annotation.UsePermission; import tech.easyflow.common.annotation.UsePermission;
import tech.easyflow.common.domain.Result; import tech.easyflow.common.domain.Result;
import tech.easyflow.common.web.controller.BaseCurdController; import tech.easyflow.common.web.controller.BaseController;
import tech.easyflow.common.web.jsonbody.JsonBody; import tech.easyflow.common.web.jsonbody.JsonBody;
import com.easyagents.core.document.Document;
import com.easyagents.core.store.DocumentStore;
import com.easyagents.core.store.StoreOptions;
import com.easyagents.core.store.StoreResult;
import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletRequest;
import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
@@ -27,12 +23,8 @@ import tech.easyflow.system.enums.ResourceAction;
import tech.easyflow.system.enums.ResourceLookup; import tech.easyflow.system.enums.ResourceLookup;
import tech.easyflow.system.permission.resource.RequireResourceAccess; import tech.easyflow.system.permission.resource.RequireResourceAccess;
import javax.annotation.Resource;
import java.math.BigInteger; import java.math.BigInteger;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map;
/** /**
* 控制层。 * 控制层。
@@ -43,19 +35,12 @@ import java.util.Map;
@RestController @RestController
@RequestMapping("/api/v1/documentChunk") @RequestMapping("/api/v1/documentChunk")
@UsePermission(moduleName = "/api/v1/documentCollection") @UsePermission(moduleName = "/api/v1/documentCollection")
public class DocumentChunkController extends BaseCurdController<DocumentChunkService, DocumentChunk> { public class DocumentChunkController extends BaseController {
@Resource private final DocumentChunkService documentChunkService;
DocumentCollectionService documentCollectionService;
@Resource
ModelService modelService;
@Resource
DocumentChunkService documentChunkService;
public DocumentChunkController(DocumentChunkService service) { public DocumentChunkController(DocumentChunkService service) {
super(service); this.documentChunkService = service;
} }
@GetMapping("page") @GetMapping("page")
@@ -67,9 +52,30 @@ public class DocumentChunkController extends BaseCurdController<DocumentChunkSer
idExpr = "#request.getParameter('documentId')", idExpr = "#request.getParameter('documentId')",
denyMessage = "无权限访问知识库" denyMessage = "无权限访问知识库"
) )
@Override public Result<Page<DocumentChunk>> page(
public Result<Page<DocumentChunk>> page(HttpServletRequest request, String sortKey, String sortType, Long pageNumber, Long pageSize) { HttpServletRequest request,
return super.page(request, sortKey, sortType, pageNumber, pageSize); Long pageNumber,
Long pageSize
) {
String documentIdValue = request.getParameter("documentId");
if (documentIdValue == null || documentIdValue.isBlank()) {
return Result.<Page<DocumentChunk>>fail("documentId不能为空", null);
}
BigInteger documentId;
try {
documentId = new BigInteger(documentIdValue);
} catch (NumberFormatException e) {
return Result.<Page<DocumentChunk>>fail("documentId格式不正确", null);
}
long normalizedPageNumber = pageNumber == null || pageNumber < 1 ? 1 : pageNumber;
long normalizedPageSize = pageSize == null || pageSize < 1 ? 10 : pageSize;
QueryWrapper query = QueryWrapper.create()
.eq(DocumentChunk::getDocumentId, documentId)
.orderBy("sorting asc");
return Result.ok(documentChunkService.page(
new Page<>(normalizedPageNumber, normalizedPageSize),
query
));
} }
@PostMapping("update") @PostMapping("update")
@@ -78,39 +84,23 @@ public class DocumentChunkController extends BaseCurdController<DocumentChunkSer
resource = CategoryResourceType.KNOWLEDGE, resource = CategoryResourceType.KNOWLEDGE,
action = ResourceAction.MANAGE, action = ResourceAction.MANAGE,
lookup = ResourceLookup.DOCUMENT_CHUNK_ID, lookup = ResourceLookup.DOCUMENT_CHUNK_ID,
idExpr = "#documentChunk.id", idExpr = "#request.id",
denyMessage = "无权限管理知识库" denyMessage = "无权限管理知识库"
) )
public Result<?> update(@JsonBody DocumentChunk documentChunk) { public Result<?> update(
boolean success = service.updateById(documentChunk); @JsonBody(required = true, skipConvertError = false)
if (success){ DocumentChunkContentUpdateRequest request
DocumentChunk record = documentChunkService.getById(documentChunk.getId()); ) {
DocumentCollection knowledge = documentCollectionService.getById(record.getDocumentCollectionId()); DocumentChunk current = documentChunkService.getById(request.getId());
if (knowledge == null) { if (current == null) {
return Result.fail(1, "知识库不存在"); return Result.fail(1, "记录不存在");
} }
DocumentStore documentStore = knowledge.toDocumentStore(); DocumentChunk updated = documentChunkService.updateContent(
if (documentStore == null) { current.getDocumentCollectionId(),
return Result.fail(2, "知识库没有配置向量库"); current.getId(),
} request.getContent()
// 设置向量模型 );
Model model = modelService.getModelInstance(knowledge.getVectorEmbedModelId()); return Result.ok(updated);
if (model == null) {
return Result.fail(3, "知识库没有配置向量模型");
}
EmbeddingModel embeddingModel = model.toEmbeddingModel();
documentStore.setEmbeddingModel(embeddingModel);
StoreOptions options = StoreOptions.ofCollectionName(knowledge.getVectorStoreCollection());
Document document = Document.of(documentChunk.getContent());
document.setId(documentChunk.getId());
Map<String, Object> metadata = new HashMap<>();
metadata.put("keywords", documentChunk.getMetadataKeyWords());
metadata.put("questions", documentChunk.getMetadataQuestions());
document.setMetadataMap(metadata);
StoreResult result = documentStore.update(document, options); // 更新已有记录
return Result.ok(result);
}
return Result.ok(false);
} }
@PostMapping("removeChunk") @PostMapping("removeChunk")
@@ -122,32 +112,58 @@ public class DocumentChunkController extends BaseCurdController<DocumentChunkSer
idExpr = "#chunkId", idExpr = "#chunkId",
denyMessage = "无权限管理知识库" denyMessage = "无权限管理知识库"
) )
public Result<?> remove(@JsonBody(value = "id", required = true) BigInteger chunkId) { public Result<?> removeChunk(@JsonBody(value = "id", required = true) BigInteger chunkId) {
DocumentChunk docChunk = documentChunkService.getById(chunkId); DocumentChunk docChunk = documentChunkService.getById(chunkId);
if (docChunk == null) { if (docChunk == null) {
return Result.fail(1, "记录不存在"); return Result.fail(1, "记录不存在");
} }
DocumentCollection knowledge = documentCollectionService.getById(docChunk.getDocumentCollectionId()); return Result.ok(documentChunkService.deleteChunk(
if (knowledge == null) { docChunk.getDocumentCollectionId(),
return Result.fail(2, "知识库不存在"); chunkId
));
} }
DocumentStore documentStore = knowledge.toDocumentStore();
if (documentStore == null) {
return Result.fail(3, "知识库没有配置向量库");
}
// 设置向量模型
Model model = modelService.getModelInstance(knowledge.getVectorEmbedModelId());
if (model == null) {
return Result.fail(4, "知识库没有配置向量模型");
}
EmbeddingModel embeddingModel = model.toEmbeddingModel();
documentStore.setEmbeddingModel(embeddingModel);
StoreOptions options = StoreOptions.ofCollectionName(knowledge.getVectorStoreCollection());
List<BigInteger> deleteList = new ArrayList<>();
deleteList.add(chunkId);
documentStore.delete(deleteList, options);
documentChunkService.removeChunk(knowledge, chunkId);
return super.remove(chunkId); @PostMapping("syncStatus")
@SaCheckPermission("/api/v1/documentCollection/query")
@RequireResourceAccess(
resource = CategoryResourceType.KNOWLEDGE,
action = ResourceAction.READ,
lookup = ResourceLookup.DOCUMENT_ID,
idExpr = "#request.documentId",
denyMessage = "无权限访问知识库"
)
public Result<List<DocumentChunkSyncStatus>> syncStatus(
@JsonBody(required = true, skipConvertError = false)
DocumentChunkSyncStatusRequest request
) {
return Result.ok(documentChunkService.listIndexSyncStatus(
null,
request.getDocumentId(),
request.getIds()
));
}
@PostMapping("retrySync")
@SaCheckPermission("/api/v1/documentCollection/save")
@RequireResourceAccess(
resource = CategoryResourceType.KNOWLEDGE,
action = ResourceAction.MANAGE,
lookup = ResourceLookup.DOCUMENT_CHUNK_ID,
idExpr = "#request.id",
denyMessage = "无权限管理知识库"
)
public Result<?> retrySync(
@JsonBody(required = true, skipConvertError = false)
DocumentChunkSyncRetryRequest request
) {
DocumentChunk current = documentChunkService.getById(request.getId());
if (current == null || request.getIndexSyncVersion() == null) {
return Result.fail(1, "记录不存在或同步版本缺失");
}
return Result.ok(documentChunkService.retryIndexSync(
current.getDocumentCollectionId(),
current.getId(),
request.getIndexSyncVersion()
));
} }
} }

View File

@@ -14,10 +14,11 @@ import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
import tech.easyflow.admin.controller.ai.support.AiResourceCreatorNameSupport; import tech.easyflow.admin.controller.ai.support.AiResourceCreatorNameSupport;
import tech.easyflow.agent.entity.AgentKnowledgeBinding;
import tech.easyflow.agent.service.AgentKnowledgeBindingService;
import tech.easyflow.ai.permission.KnowledgeVisibilityQueryHelper; import tech.easyflow.ai.permission.KnowledgeVisibilityQueryHelper;
import tech.easyflow.ai.documentimport.DocumentImportDtos; import tech.easyflow.ai.documentimport.DocumentImportDtos;
import tech.easyflow.ai.dto.KnowledgeSearchResultItem; import tech.easyflow.ai.dto.KnowledgeSearchResultItem;
import tech.easyflow.ai.entity.BotDocumentCollection;
import tech.easyflow.ai.entity.DocumentCollection; import tech.easyflow.ai.entity.DocumentCollection;
import tech.easyflow.ai.entity.Model; import tech.easyflow.ai.entity.Model;
import tech.easyflow.ai.enums.PublishStatus; import tech.easyflow.ai.enums.PublishStatus;
@@ -27,7 +28,6 @@ import tech.easyflow.ai.vo.OfflineImpactCheckVo;
import tech.easyflow.approval.entity.vo.ApprovalActionResult; import tech.easyflow.approval.entity.vo.ApprovalActionResult;
import tech.easyflow.ai.rag.KnowledgeRetrievalRequest; import tech.easyflow.ai.rag.KnowledgeRetrievalRequest;
import tech.easyflow.ai.rag.KnowledgeRetrievalModes; import tech.easyflow.ai.rag.KnowledgeRetrievalModes;
import tech.easyflow.ai.service.BotDocumentCollectionService;
import tech.easyflow.ai.service.DocumentChunkService; import tech.easyflow.ai.service.DocumentChunkService;
import tech.easyflow.ai.service.DocumentCollectionService; import tech.easyflow.ai.service.DocumentCollectionService;
import tech.easyflow.ai.service.ModelService; import tech.easyflow.ai.service.ModelService;
@@ -68,7 +68,7 @@ public class DocumentCollectionController extends BaseCurdController<DocumentCol
private final ModelService llmService; private final ModelService llmService;
@Resource @Resource
private BotDocumentCollectionService botDocumentCollectionService; private AgentKnowledgeBindingService agentKnowledgeBindingService;
@Resource @Resource
private ResourceAccessService resourceAccessService; private ResourceAccessService resourceAccessService;
@Resource @Resource
@@ -86,6 +86,16 @@ public class DocumentCollectionController extends BaseCurdController<DocumentCol
this.llmService = llmService; this.llmService = llmService;
} }
/**
* 获取知识库列表关键字搜索字段。
*
* @return 标题和描述属性
*/
@Override
protected String[] getKeywordSearchProperties() {
return new String[]{"title", "description"};
}
@Override @Override
protected Result<?> onSaveOrUpdateBefore(DocumentCollection entity, boolean isSave) { protected Result<?> onSaveOrUpdateBefore(DocumentCollection entity, boolean isSave) {
normalizeVisibilityScope(entity, isSave); normalizeVisibilityScope(entity, isSave);
@@ -169,11 +179,11 @@ public class DocumentCollectionController extends BaseCurdController<DocumentCol
} }
QueryWrapper queryWrapper = QueryWrapper.create(); QueryWrapper queryWrapper = QueryWrapper.create();
queryWrapper.in(BotDocumentCollection::getDocumentCollectionId, ids); queryWrapper.in(AgentKnowledgeBinding::getKnowledgeId, ids);
boolean exists = botDocumentCollectionService.exists(queryWrapper); boolean exists = agentKnowledgeBindingService.exists(queryWrapper);
if (exists){ if (exists){
throw new BusinessException("此知识库还关联着bot请先取消关联"); throw new BusinessException("此知识库仍被智能体使用,请先取消绑定后再删除");
} }
return null; return null;
@@ -203,18 +213,34 @@ public class DocumentCollectionController extends BaseCurdController<DocumentCol
* 提交发布审批。 * 提交发布审批。
* *
* @param id 知识库 ID * @param id 知识库 ID
* @param applicationReason 审批说明
* @return 审批实例 ID * @return 审批实例 ID
*/ */
@PostMapping("/submitPublishApproval") @PostMapping("/submitPublishApproval")
@SaCheckPermission("/api/v1/documentCollection/save") @SaCheckPermission("/api/v1/documentCollection/save")
public Result<BigInteger> submitPublishApproval(@JsonBody("id") BigInteger id) { public Result<BigInteger> submitPublishApproval(
@JsonBody("id") BigInteger id,
@JsonBody("applicationReason") String applicationReason
) {
return buildApprovalActionResult( return buildApprovalActionResult(
knowledgePublishAppService.submitPublishApproval(id), knowledgePublishAppService.submitPublishApproval(id, applicationReason),
"已提交发布审批", "已提交发布审批",
"已直接发布" "已直接发布"
); );
} }
/**
* 预检知识库发布是否命中审批流。
*
* @param id 知识库 ID
* @return 是否需要审批
*/
@GetMapping("/publishApprovalRequirement")
@SaCheckPermission("/api/v1/documentCollection/save")
public Result<Boolean> publishApprovalRequirement(@RequestParam BigInteger id) {
return Result.ok(knowledgePublishAppService.isPublishApprovalRequired(id));
}
/** /**
* 提交下线审批。 * 提交下线审批。
* *

View File

@@ -11,8 +11,11 @@ import org.springframework.core.io.ClassPathResource;
import org.springframework.http.MediaType; import org.springframework.http.MediaType;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import tech.easyflow.ai.documentimport.DocumentImportBatchDtos;
import tech.easyflow.ai.documentimport.DocumentImportDtos; import tech.easyflow.ai.documentimport.DocumentImportDtos;
import tech.easyflow.ai.documentimport.task.DocumentImportBatchAppService;
import tech.easyflow.ai.documentimport.task.DocumentImportTaskStatusStreamService; import tech.easyflow.ai.documentimport.task.DocumentImportTaskStatusStreamService;
import tech.easyflow.ai.entity.Document; import tech.easyflow.ai.entity.Document;
import tech.easyflow.ai.entity.DocumentCollection; import tech.easyflow.ai.entity.DocumentCollection;
@@ -83,6 +86,9 @@ public class DocumentController extends BaseCurdController<DocumentService, Docu
@Autowired @Autowired
private DocumentImportTaskStatusStreamService documentImportTaskStatusStreamService; private DocumentImportTaskStatusStreamService documentImportTaskStatusStreamService;
@Autowired
private DocumentImportBatchAppService documentImportBatchAppService;
@Value("${easyflow.storage.local.root:}") @Value("${easyflow.storage.local.root:}")
private String fileUploadPath; private String fileUploadPath;
@@ -93,6 +99,16 @@ public class DocumentController extends BaseCurdController<DocumentService, Docu
super(service); super(service);
this.knowledgeService = knowledgeService; this.knowledgeService = knowledgeService;
} }
/**
* 获取知识库文档关键字搜索字段。
*
* @return 文件标题属性
*/
@Override
protected String[] getKeywordSearchProperties() {
return new String[]{"title"};
}
@PostMapping("removeDoc") @PostMapping("removeDoc")
@Transactional @Transactional
@SaCheckPermission("/api/v1/documentCollection/remove") @SaCheckPermission("/api/v1/documentCollection/remove")
@@ -102,12 +118,10 @@ public class DocumentController extends BaseCurdController<DocumentService, Docu
List<Serializable> ids = Collections.singletonList(id); List<Serializable> ids = Collections.singletonList(id);
Result<?> result = onRemoveBefore(ids); Result<?> result = onRemoveBefore(ids);
if (result != null) return result; if (result != null) return result;
boolean isSuccess = documentService.removeDoc(id); boolean success = documentService.removeDoc(id);
if (!isSuccess){ if (success) {
return Result.ok(false);
}
boolean success = service.removeById(id);
onRemoveAfter(ids); onRemoveAfter(ids);
}
return Result.ok(success); return Result.ok(success);
} }
@@ -142,13 +156,19 @@ public class DocumentController extends BaseCurdController<DocumentService, Docu
@GetMapping("documentList") @GetMapping("documentList")
@SaCheckPermission("/api/v1/documentCollection/query") @SaCheckPermission("/api/v1/documentCollection/query")
public Result<Page<Document>> documentList(@RequestParam(name="title", required = false) String fileName, @RequestParam(name="pageSize") int pageSize, @RequestParam(name = "pageNumber") int pageNumber) { public Result<Page<Document>> documentList(
@RequestParam(name = "keyword", required = false) String keyword,
@RequestParam(name = "title", required = false) String legacyTitle,
@RequestParam(name = "pageSize") int pageSize,
@RequestParam(name = "pageNumber") int pageNumber) {
String kbSlug = RequestUtil.getParamAsString("id"); String kbSlug = RequestUtil.getParamAsString("id");
if (StringUtil.noText(kbSlug)) { if (StringUtil.noText(kbSlug)) {
throw new BusinessException("知识库id不能为空"); throw new BusinessException("知识库id不能为空");
} }
DocumentCollection knowledge = getDocumentCollection(kbSlug, ResourceAction.READ, "无权限访问知识库"); DocumentCollection knowledge = getDocumentCollection(kbSlug, ResourceAction.READ, "无权限访问知识库");
Page<Document> documentList = documentService.getDocumentList(knowledge.getId().toString(), pageSize, pageNumber,fileName); String effectiveKeyword = StringUtil.hasText(keyword) ? keyword : legacyTitle;
Page<Document> documentList = documentService.getDocumentList(
knowledge.getId().toString(), pageSize, pageNumber, effectiveKeyword);
return Result.ok(documentList); return Result.ok(documentList);
} }
@@ -312,6 +332,142 @@ public class DocumentController extends BaseCurdController<DocumentService, Docu
return documentService.retryIndexTask(request); return documentService.retryIndexTask(request);
} }
/**
* 创建文档批量上传清单。
*
* @param request 文件清单
* @return 批次与服务端文件项
*/
@PostMapping("import/batch/create")
@SaCheckPermission("/api/v1/documentCollection/save")
public Result<DocumentImportBatchDtos.CreateResponse> createImportBatch(
@JsonBody DocumentImportBatchDtos.CreateRequest request) {
if (request == null || request.getKnowledgeId() == null) {
throw new BusinessException("知识库id不能为空");
}
getDocumentCollection(request.getKnowledgeId().toString(), ResourceAction.MANAGE, "无权限管理知识库");
return Result.ok(documentImportBatchAppService.createBatch(request));
}
/**
* 上传一个批次文件。
*
* @param batchId 批次 ID
* @param itemId 文件项 ID
* @param knowledgeId 知识库 ID
* @param file 上传文件
* @return 文件项状态
*/
@PostMapping(value = "import/batch/{batchId}/item/{itemId}/upload",
consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@SaCheckPermission("/api/v1/documentCollection/save")
public Result<DocumentImportBatchDtos.ItemResponse> uploadImportBatchItem(
@PathVariable BigInteger batchId,
@PathVariable BigInteger itemId,
@RequestParam BigInteger knowledgeId,
@RequestPart("file") MultipartFile file) {
getDocumentCollection(knowledgeId.toString(), ResourceAction.MANAGE, "无权限管理知识库");
return Result.ok(documentImportBatchAppService.uploadItem(
knowledgeId, batchId, itemId, file));
}
/**
* 启动手动或自动批量导入。
*
* @param request 启动请求
* @return 批次状态
*/
@PostMapping("import/batch/start")
@SaCheckPermission("/api/v1/documentCollection/save")
public Result<DocumentImportBatchDtos.StatusResponse> startImportBatch(
@JsonBody DocumentImportBatchDtos.StartRequest request) {
if (request == null || request.getKnowledgeId() == null) {
throw new BusinessException("知识库id不能为空");
}
getDocumentCollection(request.getKnowledgeId().toString(), ResourceAction.MANAGE, "无权限管理知识库");
return Result.ok(documentImportBatchAppService.startBatch(request));
}
/**
* 取消一个尚未启动的上传批次。
*
* @param knowledgeId 知识库 ID
* @param batchId 批次 ID
* @return 空结果
*/
@PostMapping("import/batch/cancel")
@SaCheckPermission("/api/v1/documentCollection/save")
public Result<Void> cancelImportBatch(
@JsonBody(value = "knowledgeId", required = true) BigInteger knowledgeId,
@JsonBody(value = "batchId", required = true) BigInteger batchId) {
getDocumentCollection(knowledgeId.toString(), ResourceAction.MANAGE, "无权限管理知识库");
documentImportBatchAppService.cancelBatch(knowledgeId, batchId);
return Result.ok();
}
/**
* 查询批次状态。
*
* @param knowledgeId 知识库 ID
* @param batchId 批次 ID
* @return 批次状态
*/
@GetMapping("import/batch/status")
@SaCheckPermission("/api/v1/documentCollection/query")
public Result<DocumentImportBatchDtos.StatusResponse> getImportBatchStatus(
@RequestParam BigInteger knowledgeId,
@RequestParam BigInteger batchId) {
getDocumentCollection(knowledgeId.toString(), ResourceAction.READ, "无权限访问知识库");
return Result.ok(documentImportBatchAppService.getBatchStatus(knowledgeId, batchId));
}
/**
* 查询知识库最近一个自动导入批次。
*
* @param knowledgeId 知识库 ID
* @return 最近批次状态
*/
@GetMapping("import/batch/current")
@SaCheckPermission("/api/v1/documentCollection/query")
public Result<DocumentImportBatchDtos.StatusResponse> getCurrentImportBatch(
@RequestParam BigInteger knowledgeId) {
getDocumentCollection(knowledgeId.toString(), ResourceAction.READ, "无权限访问知识库");
return Result.ok(documentImportBatchAppService.getLatestAutoBatch(knowledgeId));
}
/**
* 继续中断或部分失败的自动导入批次。
*
* @param knowledgeId 知识库 ID
* @param batchId 批次 ID
* @return 继续后的批次状态
*/
@PostMapping("import/batch/continue")
@SaCheckPermission("/api/v1/documentCollection/save")
public Result<DocumentImportBatchDtos.StatusResponse> continueImportBatch(
@JsonBody(value = "knowledgeId", required = true) BigInteger knowledgeId,
@JsonBody(value = "batchId", required = true) BigInteger batchId) {
getDocumentCollection(knowledgeId.toString(), ResourceAction.MANAGE, "无权限管理知识库");
return Result.ok(documentImportBatchAppService.continueBatch(knowledgeId, batchId));
}
/**
* 根据解析、分块或向量化失败阶段统一重试。
*
* @param request 重试请求
* @return 重试任务状态
*/
@PostMapping("import/task/retry")
@SaCheckPermission("/api/v1/documentCollection/save")
public Result<DocumentImportDtos.TaskStartIndexResponse> retryImportTask(
@JsonBody DocumentImportDtos.TaskRetryRequest request) {
if (request == null || request.getKnowledgeId() == null || request.getDocumentId() == null) {
throw new BusinessException("重试信息不完整");
}
getDocumentCollection(request.getKnowledgeId().toString(), ResourceAction.MANAGE, "无权限管理知识库");
return documentService.retryFailedTask(request);
}
/** /**
* 更新 entity * 更新 entity
* *

View File

@@ -2,6 +2,7 @@ package tech.easyflow.admin.controller.ai;
import cn.dev33.satoken.annotation.SaCheckPermission; import cn.dev33.satoken.annotation.SaCheckPermission;
import com.mybatisflex.core.paginate.Page; import com.mybatisflex.core.paginate.Page;
import com.mybatisflex.core.query.QueryColumn;
import com.mybatisflex.core.query.QueryWrapper; import com.mybatisflex.core.query.QueryWrapper;
import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse; import jakarta.servlet.http.HttpServletResponse;
@@ -47,7 +48,7 @@ import java.util.Set;
@UsePermission(moduleName = "/api/v1/documentCollection") @UsePermission(moduleName = "/api/v1/documentCollection")
public class FaqItemController extends BaseCurdController<FaqItemService, FaqItem> { public class FaqItemController extends BaseCurdController<FaqItemService, FaqItem> {
private static final long MAX_IMAGE_SIZE_BYTES = 5L * 1024L * 1024L; private static final long MAX_IMAGE_SIZE_BYTES = 20L * 1024L * 1024L;
private static final Set<String> ALLOWED_IMAGE_TYPES = new HashSet<>(Arrays.asList( private static final Set<String> ALLOWED_IMAGE_TYPES = new HashSet<>(Arrays.asList(
"image/jpeg", "image/jpeg",
"image/png", "image/png",
@@ -114,9 +115,14 @@ public class FaqItemController extends BaseCurdController<FaqItemService, FaqIte
QueryWrapper queryWrapper = QueryWrapper.create() QueryWrapper queryWrapper = QueryWrapper.create()
.eq(FaqItem::getCollectionId, collectionId); .eq(FaqItem::getCollectionId, collectionId);
String question = request.getParameter("question"); String keyword = normalizeSearchKeyword(request.getParameter("keyword"));
if (question != null && !question.trim().isEmpty()) { String question = normalizeSearchKeyword(request.getParameter("question"));
queryWrapper.like(FaqItem::getQuestion, question.trim()); if (StringUtils.hasText(keyword)) {
queryWrapper.and(buildLiteralContainsCondition(
keyword, new QueryColumn("question"), new QueryColumn("answer_text")));
} else if (StringUtils.hasText(question)) {
// 兼容旧客户端仅按问题字段搜索。
queryWrapper.and(buildLiteralContainsCondition(question, new QueryColumn("question")));
} }
String categoryIdText = request.getParameter("categoryId"); String categoryIdText = request.getParameter("categoryId");
@@ -215,7 +221,7 @@ public class FaqItemController extends BaseCurdController<FaqItemService, FaqIte
throw new BusinessException("图片不能为空"); throw new BusinessException("图片不能为空");
} }
if (file.getSize() > MAX_IMAGE_SIZE_BYTES) { if (file.getSize() > MAX_IMAGE_SIZE_BYTES) {
throw new BusinessException("图片大小不能超过5MB"); throw new BusinessException("图片大小不能超过20MB");
} }
if (!isAllowedImageType(file)) { if (!isAllowedImageType(file)) {
throw new BusinessException("仅支持 JPG/PNG/WEBP/GIF 图片"); throw new BusinessException("仅支持 JPG/PNG/WEBP/GIF 图片");

View File

@@ -1,5 +1,6 @@
package tech.easyflow.admin.controller.ai; package tech.easyflow.admin.controller.ai;
import com.easyagents.mcp.client.McpEnvironmentCheckResult;
import com.mybatisflex.core.paginate.Page; import com.mybatisflex.core.paginate.Page;
import com.mybatisflex.core.query.QueryWrapper; import com.mybatisflex.core.query.QueryWrapper;
import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletRequest;
@@ -8,16 +9,19 @@ import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
import tech.easyflow.ai.entity.BotMcp;
import tech.easyflow.ai.entity.Mcp; import tech.easyflow.ai.entity.Mcp;
import tech.easyflow.ai.service.BotMcpService; import tech.easyflow.ai.service.AgentResourceReferenceService;
import tech.easyflow.ai.service.McpService; import tech.easyflow.ai.service.McpService;
import tech.easyflow.common.domain.Result; import tech.easyflow.common.domain.Result;
import tech.easyflow.common.entity.LoginAccount;
import tech.easyflow.common.satoken.util.SaTokenUtil;
import tech.easyflow.common.web.controller.BaseCurdController; import tech.easyflow.common.web.controller.BaseCurdController;
import tech.easyflow.common.web.exceptions.BusinessException;
import tech.easyflow.common.web.jsonbody.JsonBody; import tech.easyflow.common.web.jsonbody.JsonBody;
import javax.annotation.Resource; import javax.annotation.Resource;
import java.io.Serializable; import java.io.Serializable;
import java.math.BigInteger;
/** /**
* 控制层。 * 控制层。
@@ -32,8 +36,18 @@ public class McpController extends BaseCurdController<McpService, Mcp> {
super(service); super(service);
} }
/**
* 获取 MCP 列表关键字搜索字段。
*
* @return 标题和描述属性
*/
@Override
protected String[] getKeywordSearchProperties() {
return new String[]{"title", "description"};
}
@Resource @Resource
private BotMcpService botMcpService; private AgentResourceReferenceService agentResourceReferenceService;
@Override @Override
public Result<?> save(Mcp entity) { public Result<?> save(Mcp entity) {
return service.saveMcp(entity); return service.saveMcp(entity);
@@ -44,11 +58,29 @@ public class McpController extends BaseCurdController<McpService, Mcp> {
return service.updateMcp(entity); return service.updateMcp(entity);
} }
/**
* 删除未被 Agent 绑定的 MCP。
*
* @param id MCP ID
* @return 删除结果
*/
@Override @Override
@Transactional @Transactional(rollbackFor = Exception.class)
public Result<?> remove(Serializable id) { public Result<?> remove(Serializable id) {
LoginAccount account = SaTokenUtil.getLoginAccount();
if (account == null || account.getTenantId() == null) {
throw new BusinessException("当前登录状态失效,请重新登录后再试");
}
// 锁定 MCP 资源行,与 Agent 绑定校验串行,避免检查后并发写入绑定。
Mcp mcp = service.getOne(QueryWrapper.create()
.eq(Mcp::getId, id)
.eq(Mcp::getTenantId, account.getTenantId())
.forUpdate());
if (mcp == null) {
throw new BusinessException("MCP 不存在或无权删除");
}
agentResourceReferenceService.assertMcpUnused(new BigInteger(String.valueOf(id)));
service.removeMcp(id); service.removeMcp(id);
botMcpService.remove(QueryWrapper.create().eq(BotMcp::getMcpId, id));
return Result.ok(); return Result.ok();
} }
@@ -64,6 +96,11 @@ public class McpController extends BaseCurdController<McpService, Mcp> {
return Result.ok(service.getMcpTools(id)); return Result.ok(service.getMcpTools(id));
} }
@PostMapping("/check")
public Result<McpEnvironmentCheckResult> check(@JsonBody("configJson") String configJson) {
return Result.ok(service.checkMcp(configJson));
}
@GetMapping("pageTools") @GetMapping("pageTools")
public Result<Page<Mcp>> pageTools(HttpServletRequest request, String sortKey, String sortType, Long pageNumber, Long pageSize) { public Result<Page<Mcp>> pageTools(HttpServletRequest request, String sortKey, String sortType, Long pageNumber, Long pageSize) {

View File

@@ -9,15 +9,21 @@ import org.springframework.util.StringUtils;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import tech.easyflow.ai.dto.ModelInvokeConfigDtos; import tech.easyflow.ai.dto.ModelInvokeConfigDtos;
import tech.easyflow.ai.entity.Model; import tech.easyflow.ai.entity.Model;
import tech.easyflow.ai.entity.ModelProvider;
import tech.easyflow.ai.entity.table.ModelTableDef; import tech.easyflow.ai.entity.table.ModelTableDef;
import tech.easyflow.ai.mapper.ModelMapper; import tech.easyflow.ai.mapper.ModelMapper;
import tech.easyflow.ai.service.AgentResourceReferenceService;
import tech.easyflow.ai.service.ModelService; import tech.easyflow.ai.service.ModelService;
import tech.easyflow.admin.model.ai.ModelGatewayConfigView;
import tech.easyflow.common.satoken.util.SaTokenUtil;
import tech.easyflow.system.entity.SysOption;
import tech.easyflow.system.service.SysOptionService;
import tech.easyflow.ai.service.capability.ModelCapabilityResolution;
import tech.easyflow.common.domain.Result; import tech.easyflow.common.domain.Result;
import tech.easyflow.common.entity.LoginAccount; import tech.easyflow.common.entity.LoginAccount;
import tech.easyflow.common.satoken.util.SaTokenUtil; import tech.easyflow.common.satoken.util.SaTokenUtil;
import tech.easyflow.common.tree.Tree; import tech.easyflow.common.tree.Tree;
import tech.easyflow.common.web.controller.BaseCurdController; import tech.easyflow.common.web.controller.BaseCurdController;
import tech.easyflow.common.web.exceptions.BusinessException;
import tech.easyflow.common.web.jsonbody.JsonBody; import tech.easyflow.common.web.jsonbody.JsonBody;
import javax.annotation.Resource; import javax.annotation.Resource;
@@ -26,7 +32,6 @@ import java.math.BigInteger;
import java.util.Collections; import java.util.Collections;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Optional;
import java.util.stream.Collectors; import java.util.stream.Collectors;
/** /**
@@ -39,6 +44,11 @@ import java.util.stream.Collectors;
@RequestMapping("/api/v1/model") @RequestMapping("/api/v1/model")
public class ModelController extends BaseCurdController<ModelService, Model> { public class ModelController extends BaseCurdController<ModelService, Model> {
private static final String CHAT_PUBLISH_BASE_URL = "chat_publish_base_url";
@Autowired
private SysOptionService sysOptionService;
public ModelController(ModelService service) { public ModelController(ModelService service) {
super(service); super(service);
} }
@@ -46,22 +56,30 @@ public class ModelController extends BaseCurdController<ModelService, Model> {
@Autowired @Autowired
ModelService modelService; ModelService modelService;
/**
* 查询模型统一网关页面所需的安全配置。
*
* @return 仅包含模型发布基础地址的配置
*/
@GetMapping("/gatewayConfig")
@SaCheckPermission("/api/v1/model/query")
public Result<ModelGatewayConfigView> gatewayConfig() {
SysOption option = sysOptionService.getByOptionKey(
CHAT_PUBLISH_BASE_URL,
SaTokenUtil.getLoginAccount().getTenantId()
);
return Result.ok(new ModelGatewayConfigView(option == null ? null : option.getValue()));
}
@Resource @Resource
ModelMapper modelMapper; ModelMapper modelMapper;
@Resource
AgentResourceReferenceService agentResourceReferenceService;
@GetMapping("list") @GetMapping("list")
@SaCheckPermission("/api/v1/model/query") @SaCheckPermission("/api/v1/model/query")
public Result<List<Model>> list(Model entity, Boolean asTree, String sortKey, String sortType) { public Result<List<Model>> list(Model entity, Boolean asTree, String sortKey, String sortType) {
QueryWrapper queryWrapper = QueryWrapper.create(entity, buildOperators(entity)); return Result.ok(service.listSelectableModels(entity, asTree, sortKey, sortType));
queryWrapper.orderBy(buildOrderBy(sortKey, sortType, getDefaultOrderBy()));
List<Model> list = Tree.tryToTree(modelMapper.selectListWithRelationsByQuery(queryWrapper), asTree);
list.forEach(item -> {
String providerName = Optional.ofNullable(item.getModelProvider())
.map(ModelProvider::getProviderName)
.orElse("-");
item.setTitle(providerName + "/" + item.getTitle());
});
return Result.ok(list);
} }
@GetMapping("getList") @GetMapping("getList")
@@ -92,9 +110,39 @@ public class ModelController extends BaseCurdController<ModelService, Model> {
return Result.ok(modelService.verifyModelConfig(model)); return Result.ok(modelService.verifyModelConfig(model));
} }
/**
* 根据模型 ID 返回自动识别的类型和能力。
*
* @param providerId 供应商 ID
* @param modelName 模型 ID
* @return 模型能力识别结果
*/
@GetMapping("capabilities")
@SaCheckPermission("/api/v1/model/query")
public Result<ModelCapabilityResolution> resolveCapabilities(
@RequestParam(required = false) BigInteger providerId,
@RequestParam String modelName) {
return Result.ok(modelService.resolveModelCapabilities(providerId, modelName));
}
@PostMapping("/removeByEntity") @PostMapping("/removeByEntity")
@SaCheckPermission("/api/v1/model/remove") @SaCheckPermission("/api/v1/model/remove")
@Transactional(rollbackFor = Exception.class)
public Result<?> removeByEntity(@RequestBody Model entity) { public Result<?> removeByEntity(@RequestBody Model entity) {
LoginAccount account = requireAccount();
List<Model> models = service.list(QueryWrapper.create()
.select(Model::getId)
.eq(Model::getProviderId, entity.getProviderId())
.eq(Model::getGroupName, entity.getGroupName())
.eq(Model::getTenantId, account.getTenantId())
.orderBy(Model::getId, true)
.forUpdate());
if (models.isEmpty()) {
throw new BusinessException("模型不存在或无权删除");
}
agentResourceReferenceService.assertModelsUnused(
models.stream().map(Model::getId).toList());
entity.setTenantId(account.getTenantId());
modelService.removeByEntity(entity); modelService.removeByEntity(entity);
return Result.ok(); return Result.ok();
} }
@@ -145,8 +193,10 @@ public class ModelController extends BaseCurdController<ModelService, Model> {
QueryWrapper queryWrapper = QueryWrapper.create(); QueryWrapper queryWrapper = QueryWrapper.create();
queryWrapper.eq(Model::getProviderId, providerId); queryWrapper.eq(Model::getProviderId, providerId);
queryWrapper.eq(Model::getModelType, modelType); queryWrapper.eq(Model::getModelType, modelType);
if (StringUtils.hasLength(selectText)) { String keyword = normalizeSearchKeyword(selectText);
queryWrapper.and(ModelTableDef.MODEL.TITLE.like(selectText).or(ModelTableDef.MODEL.MODEL_NAME.like(selectText))); if (StringUtils.hasText(keyword)) {
queryWrapper.and(buildLiteralContainsCondition(
keyword, ModelTableDef.MODEL.TITLE, ModelTableDef.MODEL.MODEL_NAME));
} }
List<Model> totalList = service.getMapper().selectListWithRelationsByQuery(queryWrapper); List<Model> totalList = service.getMapper().selectListWithRelationsByQuery(queryWrapper);
Map<String, List<Model>> groupList = totalList.stream().collect(Collectors.groupingBy(Model::getGroupName)); Map<String, List<Model>> groupList = totalList.stream().collect(Collectors.groupingBy(Model::getGroupName));
@@ -167,11 +217,39 @@ public class ModelController extends BaseCurdController<ModelService, Model> {
} }
@PostMapping("removeLlmByIds") @PostMapping("removeLlmByIds")
@Transactional @SaCheckPermission("/api/v1/model/remove")
@Transactional(rollbackFor = Exception.class)
public Result<?> removeLlm(@JsonBody(value = "id", required = true) Serializable id) { public Result<?> removeLlm(@JsonBody(value = "id", required = true) Serializable id) {
LoginAccount account = requireAccount();
List<Serializable> ids = Collections.singletonList(id); List<Serializable> ids = Collections.singletonList(id);
QueryWrapper queryWrapper = QueryWrapper.create().in(Model::getId, ids); QueryWrapper queryWrapper = QueryWrapper.create()
service.remove(queryWrapper); .in(Model::getId, ids)
.eq(Model::getTenantId, account.getTenantId())
.orderBy(Model::getId, true)
.forUpdate();
List<Model> models = service.list(queryWrapper);
if (models.isEmpty()) {
throw new BusinessException("模型不存在或无权删除");
}
agentResourceReferenceService.assertModelsUnused(
models.stream().map(Model::getId).toList());
service.remove(QueryWrapper.create()
.in(Model::getId, ids)
.eq(Model::getTenantId, account.getTenantId()));
return Result.ok(); return Result.ok();
} }
/**
* 获取带租户信息的当前登录账号。
*
* @return 当前登录账号
* @throws BusinessException 登录状态无效时抛出
*/
private LoginAccount requireAccount() {
LoginAccount account = SaTokenUtil.getLoginAccount();
if (account == null || account.getTenantId() == null) {
throw new BusinessException("当前登录状态失效,请重新登录后再试");
}
return account;
}
} }

View File

@@ -1,21 +1,33 @@
package tech.easyflow.admin.controller.ai; package tech.easyflow.admin.controller.ai;
import cn.dev33.satoken.annotation.SaCheckPermission;
import com.mybatisflex.core.query.QueryWrapper; import com.mybatisflex.core.query.QueryWrapper;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
import tech.easyflow.ai.dto.RemoteModelImportRequest;
import tech.easyflow.ai.entity.Model; import tech.easyflow.ai.entity.Model;
import tech.easyflow.ai.entity.ModelProvider; import tech.easyflow.ai.entity.ModelProvider;
import tech.easyflow.ai.service.ModelProviderService; import tech.easyflow.ai.service.ModelProviderService;
import tech.easyflow.ai.service.ModelService; import tech.easyflow.ai.service.ModelService;
import tech.easyflow.ai.service.discovery.RemoteModelDiscoveryService;
import tech.easyflow.ai.service.discovery.RemoteModelImportResult;
import tech.easyflow.ai.service.discovery.RemoteModelImportService;
import tech.easyflow.ai.service.discovery.RemoteModelListResult;
import tech.easyflow.common.annotation.UsePermission; import tech.easyflow.common.annotation.UsePermission;
import tech.easyflow.common.domain.Result; import tech.easyflow.common.domain.Result;
import tech.easyflow.common.entity.LoginAccount;
import tech.easyflow.common.satoken.util.SaTokenUtil;
import tech.easyflow.common.web.controller.BaseCurdController; import tech.easyflow.common.web.controller.BaseCurdController;
import tech.easyflow.common.web.exceptions.BusinessException; import tech.easyflow.common.web.exceptions.BusinessException;
import tech.easyflow.common.web.jsonbody.JsonBody; import tech.easyflow.common.web.jsonbody.JsonBody;
import java.io.Serializable; import java.io.Serializable;
import java.math.BigInteger;
/** /**
* 控制层。 * 控制层。
@@ -28,12 +40,33 @@ import java.io.Serializable;
@UsePermission(moduleName = "/api/v1/model") @UsePermission(moduleName = "/api/v1/model")
public class ModelProviderController extends BaseCurdController<ModelProviderService, ModelProvider> { public class ModelProviderController extends BaseCurdController<ModelProviderService, ModelProvider> {
private final ModelService modelService; private final ModelService modelService;
private final RemoteModelDiscoveryService remoteModelDiscoveryService;
private final RemoteModelImportService remoteModelImportService;
public ModelProviderController(ModelProviderService service, ModelService modelService) { /**
* 创建模型服务商控制器。
*
* @param service 模型服务商服务
* @param modelService 模型服务
* @param remoteModelDiscoveryService 远端模型发现服务
* @param remoteModelImportService 远端模型一键添加服务
*/
public ModelProviderController(ModelProviderService service,
ModelService modelService,
RemoteModelDiscoveryService remoteModelDiscoveryService,
RemoteModelImportService remoteModelImportService) {
super(service); super(service);
this.modelService = modelService; this.modelService = modelService;
this.remoteModelDiscoveryService = remoteModelDiscoveryService;
this.remoteModelImportService = remoteModelImportService;
} }
/**
* 删除没有子模型的服务商。
*
* @param id 服务商 ID
* @return 删除结果
*/
@Override @Override
@PostMapping("remove") @PostMapping("remove")
@Transactional @Transactional
@@ -45,4 +78,35 @@ public class ModelProviderController extends BaseCurdController<ModelProviderSer
} }
return Result.ok(service.removeById(id)); return Result.ok(service.removeById(id));
} }
/**
* 主动获取当前服务商的远端模型列表。
*
* @param providerId 服务商 ID
* @return 已补全能力和本地添加状态的远端模型列表
*/
@GetMapping("{providerId}/remoteModels")
@SaCheckPermission("/api/v1/model/save")
public Result<RemoteModelListResult> remoteModels(@PathVariable BigInteger providerId) {
return Result.ok(remoteModelDiscoveryService.discover(providerId));
}
/**
* 幂等添加单个远端模型。
*
* @param providerId 服务商 ID
* @param request 一键添加请求
* @return 创建或已存在结果
*/
@PostMapping("{providerId}/remoteModels/import")
@SaCheckPermission("/api/v1/model/save")
public Result<RemoteModelImportResult> importRemoteModel(
@PathVariable BigInteger providerId,
@RequestBody RemoteModelImportRequest request) {
LoginAccount account = SaTokenUtil.getLoginAccount();
Model model = new Model();
commonFiled(model, account.getId(), account.getTenantId(), account.getDeptId());
String modelId = request == null ? null : request.getModelId();
return Result.ok(remoteModelImportService.importModel(providerId, modelId, model));
}
} }

View File

@@ -2,6 +2,7 @@ package tech.easyflow.admin.controller.ai;
import cn.dev33.satoken.annotation.SaCheckPermission; import cn.dev33.satoken.annotation.SaCheckPermission;
import com.mybatisflex.core.paginate.Page; import com.mybatisflex.core.paginate.Page;
import com.mybatisflex.core.query.QueryColumn;
import com.mybatisflex.core.query.QueryWrapper; import com.mybatisflex.core.query.QueryWrapper;
import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletRequest;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
@@ -12,6 +13,7 @@ import tech.easyflow.ai.entity.Plugin;
import tech.easyflow.ai.entity.Workflow; import tech.easyflow.ai.entity.Workflow;
import tech.easyflow.ai.plugin.workflow.snapshot.WorkflowPluginSnapshotResolver; import tech.easyflow.ai.plugin.workflow.snapshot.WorkflowPluginSnapshotResolver;
import tech.easyflow.ai.service.ModelService; import tech.easyflow.ai.service.ModelService;
import tech.easyflow.ai.service.PluginItemService;
import tech.easyflow.ai.service.PluginVisibilityService; import tech.easyflow.ai.service.PluginVisibilityService;
import tech.easyflow.ai.permission.WorkflowVisibilityQueryHelper; import tech.easyflow.ai.permission.WorkflowVisibilityQueryHelper;
import tech.easyflow.ai.service.WorkflowService; import tech.easyflow.ai.service.WorkflowService;
@@ -47,8 +49,24 @@ import static tech.easyflow.ai.entity.table.PluginTableDef.PLUGIN;
@RestController @RestController
@RequestMapping("/api/v1/plugin") @RequestMapping("/api/v1/plugin")
public class PluginController extends BaseCurdController<PluginService, Plugin> { public class PluginController extends BaseCurdController<PluginService, Plugin> {
/**
* 创建插件控制器。
*
* @param service 插件服务
*/
public PluginController(PluginService service) { public PluginController(PluginService service) {
super(service); super(service);
this.pluginService = service;
}
/**
* 获取插件列表关键字搜索字段。
*
* @return 插件名称和描述属性
*/
@Override
protected String[] getKeywordSearchProperties() {
return new String[]{"name", "description"};
} }
@Resource @Resource
@@ -89,10 +107,15 @@ public class PluginController extends BaseCurdController<PluginService, Plugin>
return Result.ok(pluginService.updatePlugin(plugin)); return Result.ok(pluginService.updatePlugin(plugin));
} }
/**
* 删除插件。
*
* @param id 插件 ID
* @return 删除结果
*/
@PostMapping("/plugin/remove") @PostMapping("/plugin/remove")
@SaCheckPermission("/api/v1/plugin/remove") @SaCheckPermission("/api/v1/plugin/remove")
public Result<Boolean> removePlugin(@JsonBody(value = "id", required = true) String id){ public Result<Boolean> removePlugin(@JsonBody(value = "id", required = true) String id){
return Result.ok(pluginService.removePlugin(id)); return Result.ok(pluginService.removePlugin(id));
} }
@@ -105,9 +128,24 @@ public class PluginController extends BaseCurdController<PluginService, Plugin>
return Result.ok(pluginService.preparePluginsForCurrentUser(plugins, true, false)); return Result.ok(pluginService.preparePluginsForCurrentUser(plugins, true, false));
} }
/**
* 按分类分页查询插件,并支持按名称、描述模糊查询。
*
* @param request 当前请求
* @param sortKey 排序字段
* @param sortType 排序方向
* @param pageNumber 页码
* @param pageSize 每页数量
* @param category 分类 ID0 表示全部分类
* @param keyword 插件名称或描述关键字
* @param name 兼容旧客户端的插件名称关键字
* @return 插件分页结果
*/
@GetMapping("/pageByCategory") @GetMapping("/pageByCategory")
@SaCheckPermission("/api/v1/plugin/query") @SaCheckPermission("/api/v1/plugin/query")
public Result<Page<Plugin>> pageByCategory(HttpServletRequest request, String sortKey, String sortType, Long pageNumber, Long pageSize, int category) { public Result<Page<Plugin>> pageByCategory(HttpServletRequest request, String sortKey, String sortType,
Long pageNumber, Long pageSize, int category,
String keyword, String name) {
if (pageNumber == null || pageNumber < 1) { if (pageNumber == null || pageNumber < 1) {
pageNumber = 1L; pageNumber = 1L;
} }
@@ -120,7 +158,10 @@ public class PluginController extends BaseCurdController<PluginService, Plugin>
queryWrapper.orderBy(buildOrderBy(sortKey, sortType, getDefaultOrderBy())); queryWrapper.orderBy(buildOrderBy(sortKey, sortType, getDefaultOrderBy()));
return Result.ok(queryPage(new Page<>(pageNumber, pageSize), queryWrapper)); return Result.ok(queryPage(new Page<>(pageNumber, pageSize), queryWrapper));
} else { } else {
Result<Page<Plugin>> result = pluginService.pageByCategory(pageNumber, pageSize, category); String effectiveKeyword = normalizeSearchKeyword(
keyword == null || keyword.isBlank() ? name : keyword);
Result<Page<Plugin>> result = pluginService.pageByCategory(
pageNumber, pageSize, category, effectiveKeyword);
if (result != null && result.getData() != null) { if (result != null && result.getData() != null) {
aiResourceCreatorNameSupport.fillPluginCreatorNames(result.getData().getRecords()); aiResourceCreatorNameSupport.fillPluginCreatorNames(result.getData().getRecords());
} }
@@ -135,7 +176,7 @@ public class PluginController extends BaseCurdController<PluginService, Plugin>
workflowVisibilityQueryHelper.applyReadableAccess(queryWrapper); workflowVisibilityQueryHelper.applyReadableAccess(queryWrapper);
queryWrapper.eq("publish_status", tech.easyflow.ai.enums.PublishStatus.PUBLISHED.getCode()); queryWrapper.eq("publish_status", tech.easyflow.ai.enums.PublishStatus.PUBLISHED.getCode());
if (keyword != null && !keyword.isBlank()) { if (keyword != null && !keyword.isBlank()) {
queryWrapper.like("title", keyword.trim()); queryWrapper.and(buildLiteralContainsCondition(keyword, new QueryColumn("title")));
} }
queryWrapper.orderBy("modified desc"); queryWrapper.orderBy("modified desc");
LoginAccount loginAccount = SaTokenUtil.getLoginAccount(); LoginAccount loginAccount = SaTokenUtil.getLoginAccount();

View File

@@ -18,20 +18,24 @@ import tech.easyflow.ai.easyagentsflow.entity.NodeInfo;
import tech.easyflow.ai.easyagentsflow.entity.WorkflowCheckStage; import tech.easyflow.ai.easyagentsflow.entity.WorkflowCheckStage;
import tech.easyflow.ai.easyagentsflow.service.TinyFlowService; import tech.easyflow.ai.easyagentsflow.service.TinyFlowService;
import tech.easyflow.ai.easyagentsflow.service.WorkflowCheckService; import tech.easyflow.ai.easyagentsflow.service.WorkflowCheckService;
import tech.easyflow.ai.easyagentsflow.service.WorkflowResumeService;
import tech.easyflow.ai.easyagentsflow.support.PublishedWorkflowDefinitionIds; import tech.easyflow.ai.easyagentsflow.support.PublishedWorkflowDefinitionIds;
import tech.easyflow.ai.entity.BotPlugin;
import tech.easyflow.ai.entity.Plugin; import tech.easyflow.ai.entity.Plugin;
import tech.easyflow.ai.entity.PluginItem; import tech.easyflow.ai.entity.PluginItem;
import tech.easyflow.ai.entity.Workflow; import tech.easyflow.ai.entity.Workflow;
import tech.easyflow.ai.entity.WorkflowExecResult;
import tech.easyflow.ai.enums.PluginType; import tech.easyflow.ai.enums.PluginType;
import tech.easyflow.ai.plugin.workflow.snapshot.WorkflowPluginSnapshotResolver; import tech.easyflow.ai.plugin.workflow.snapshot.WorkflowPluginSnapshotResolver;
import tech.easyflow.ai.service.BotPluginService;
import tech.easyflow.ai.service.PluginService; import tech.easyflow.ai.service.PluginService;
import tech.easyflow.ai.service.PluginItemService; import tech.easyflow.ai.service.PluginItemService;
import tech.easyflow.ai.service.AgentResourceReferenceService;
import tech.easyflow.ai.service.PluginVisibilityService;
import tech.easyflow.ai.service.WorkflowService; import tech.easyflow.ai.service.WorkflowService;
import tech.easyflow.ai.service.WorkflowExecResultService;
import tech.easyflow.common.constant.Constants; import tech.easyflow.common.constant.Constants;
import tech.easyflow.common.annotation.UsePermission; import tech.easyflow.common.annotation.UsePermission;
import tech.easyflow.common.domain.Result; import tech.easyflow.common.domain.Result;
import tech.easyflow.common.entity.LoginAccount;
import tech.easyflow.common.satoken.util.SaTokenUtil; import tech.easyflow.common.satoken.util.SaTokenUtil;
import tech.easyflow.common.web.controller.BaseCurdController; import tech.easyflow.common.web.controller.BaseCurdController;
import tech.easyflow.common.web.exceptions.BusinessException; import tech.easyflow.common.web.exceptions.BusinessException;
@@ -42,8 +46,10 @@ import java.io.Serializable;
import java.math.BigInteger; import java.math.BigInteger;
import java.util.Collection; import java.util.Collection;
import java.util.HashMap; import java.util.HashMap;
import java.util.LinkedHashSet;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Set;
/** /**
* 控制层。 * 控制层。
@@ -55,27 +61,48 @@ import java.util.Map;
@RequestMapping("/api/v1/pluginItem") @RequestMapping("/api/v1/pluginItem")
@UsePermission(moduleName = "/api/v1/plugin") @UsePermission(moduleName = "/api/v1/plugin")
public class PluginItemController extends BaseCurdController<PluginItemService, PluginItem> { public class PluginItemController extends BaseCurdController<PluginItemService, PluginItem> {
/**
* 创建插件工具控制器。
*
* @param service 插件工具服务
*/
public PluginItemController(PluginItemService service) { public PluginItemController(PluginItemService service) {
super(service); super(service);
} }
/**
* 获取插件工具列表关键字搜索字段。
*
* @return 工具名称和描述属性
*/
@Override
protected String[] getKeywordSearchProperties() {
return new String[]{"name", "description"};
}
@Resource @Resource
private PluginItemService pluginItemService; private PluginItemService pluginItemService;
@Resource @Resource
private BotPluginService botPluginService; private AgentResourceReferenceService agentResourceReferenceService;
@Resource @Resource
private PluginService pluginService; private PluginService pluginService;
@Resource @Resource
private PluginVisibilityService pluginVisibilityService;
@Resource
private WorkflowPluginSnapshotResolver workflowPluginSnapshotResolver; private WorkflowPluginSnapshotResolver workflowPluginSnapshotResolver;
@Resource @Resource
private WorkflowService workflowService; private WorkflowService workflowService;
@Resource @Resource
private WorkflowExecResultService workflowExecResultService;
@Resource
private ChainExecutor chainExecutor; private ChainExecutor chainExecutor;
@Resource @Resource
private TinyFlowService tinyFlowService; private TinyFlowService tinyFlowService;
@Resource @Resource
private WorkflowCheckService workflowCheckService; private WorkflowCheckService workflowCheckService;
@Resource
private WorkflowResumeService workflowResumeService;
@PostMapping("/tool/save") @PostMapping("/tool/save")
@SaCheckPermission("/api/v1/plugin/save") @SaCheckPermission("/api/v1/plugin/save")
@@ -91,25 +118,12 @@ public class PluginItemController extends BaseCurdController<PluginItemService,
return pluginItemService.searchPlugin(aiPluginToolId); return pluginItemService.searchPlugin(aiPluginToolId);
} }
@PostMapping("/toolsList")
@SaCheckPermission("/api/v1/plugin/query")
public Result<List<PluginItem>> searchPluginToolByPluginId(@JsonBody(value = "pluginId", required = true) BigInteger pluginId,
@JsonBody(value = "botId", required = false) BigInteger botId){
return Result.ok(pluginItemService.searchPluginToolByPluginId(pluginId, botId));
}
@PostMapping("/tool/update") @PostMapping("/tool/update")
@SaCheckPermission("/api/v1/plugin/save") @SaCheckPermission("/api/v1/plugin/save")
public Result<Boolean> updatePlugin(@JsonBody PluginItem pluginItem){ public Result<Boolean> updatePlugin(@JsonBody PluginItem pluginItem){
return Result.ok(pluginItemService.updatePlugin(pluginItem)); return Result.ok(pluginItemService.updatePlugin(pluginItem));
} }
@PostMapping("/tool/list")
@SaCheckPermission("/api/v1/plugin/query")
public Result<List<PluginItem>> getPluginToolList(@JsonBody(value = "botId", required = true) BigInteger botId){
return Result.ok(pluginItemService.getPluginToolList(botId));
}
@GetMapping("/getTinyFlowData") @GetMapping("/getTinyFlowData")
@SaCheckPermission("/api/v1/plugin/query") @SaCheckPermission("/api/v1/plugin/query")
public Result<?> getTinyFlowData(BigInteger id) { public Result<?> getTinyFlowData(BigInteger id) {
@@ -208,6 +222,7 @@ public class PluginItemController extends BaseCurdController<PluginItemService,
@SaCheckPermission("/api/v1/plugin/query") @SaCheckPermission("/api/v1/plugin/query")
public Result<ChainInfo> pluginToolTestChainStatus(@JsonBody(value = "executeId", required = true) String executeId, public Result<ChainInfo> pluginToolTestChainStatus(@JsonBody(value = "executeId", required = true) String executeId,
@JsonBody("nodes") List<NodeInfo> nodes) { @JsonBody("nodes") List<NodeInfo> nodes) {
assertPluginTestExecutionOwnership(executeId);
return Result.ok(tinyFlowService.getChainStatus(executeId, nodes)); return Result.ok(tinyFlowService.getChainStatus(executeId, nodes));
} }
@@ -222,10 +237,33 @@ public class PluginItemController extends BaseCurdController<PluginItemService,
@SaCheckPermission("/api/v1/plugin/query") @SaCheckPermission("/api/v1/plugin/query")
public Result<Void> pluginToolTestResume(@JsonBody(value = "executeId", required = true) String executeId, public Result<Void> pluginToolTestResume(@JsonBody(value = "executeId", required = true) String executeId,
@JsonBody("confirmParams") Map<String, Object> confirmParams) { @JsonBody("confirmParams") Map<String, Object> confirmParams) {
chainExecutor.resumeAsync(executeId, confirmParams); assertPluginTestExecutionOwnership(executeId);
workflowResumeService.resume(executeId, confirmParams);
return Result.ok(); return Result.ok();
} }
/**
* 校验插件试运行实例由当前登录用户发起。
*
* @param executeId 执行实例 ID
*/
private void assertPluginTestExecutionOwnership(String executeId) {
if (StrUtil.isBlank(executeId)) {
throw new BusinessException("执行ID不能为空");
}
WorkflowExecResult record = workflowExecResultService.getByExecKey(executeId);
if (record == null) {
throw new BusinessException(404, 404, "工作流执行记录不存在或已过期");
}
LoginAccount currentAccount = SaTokenUtil.getLoginAccount();
if (currentAccount == null
|| currentAccount.getId() == null
|| record.getCreatedBy() == null
|| !currentAccount.getId().toString().equals(record.getCreatedBy())) {
throw new BusinessException(403, 403, "无权限访问当前插件试运行实例");
}
}
private void handleArray(JSONArray array) { private void handleArray(JSONArray array) {
for (Object o : array) { for (Object o : array) {
JSONObject obj = (JSONObject) o; JSONObject obj = (JSONObject) o;
@@ -275,26 +313,64 @@ public class PluginItemController extends BaseCurdController<PluginItemService,
return plugin; return plugin;
} }
/**
* 删除插件工具前锁定资源并校验 Agent 绑定。
*
* @param ids 插件工具 ID 集合
* @return 校验失败结果;允许删除时返回 {@code null}
*/
@Override @Override
protected Result<?> onRemoveBefore(Collection<Serializable> ids) { protected Result<?> onRemoveBefore(Collection<Serializable> ids) {
Set<BigInteger> uniquePluginItemIds = new LinkedHashSet<>();
QueryWrapper queryWrapper = QueryWrapper.create(); try {
queryWrapper.in(BotPlugin::getPluginItemId, ids); for (Serializable id : ids) {
if (id == null) {
boolean exists = botPluginService.exists(queryWrapper); throw new NumberFormatException("null");
if (exists){
return Result.fail(1, "此工具还关联着bot请先取消关联");
} }
if (ids.size() == 1) { uniquePluginItemIds.add(new BigInteger(String.valueOf(id)));
PluginItem pluginItem = pluginItemService.getById(ids.iterator().next()); }
if (pluginItem != null) { } catch (NumberFormatException exception) {
Plugin plugin = pluginService.getById(pluginItem.getPluginId()); throw new BusinessException("插件工具 ID 不合法");
if (plugin != null && PluginType.isWorkflow(plugin.getType())) { }
// BaseCurdController#remove 已开启事务;锁定工具行后校验权限和 Agent 引用。
List<PluginItem> lockedPluginItems = pluginItemService.list(QueryWrapper.create()
.in(PluginItem::getId, uniquePluginItemIds)
.orderBy(PluginItem::getId, true)
.forUpdate());
if (lockedPluginItems == null || lockedPluginItems.size() != uniquePluginItemIds.size()) {
throw new BusinessException("插件工具不存在或已被删除");
}
LoginAccount loginAccount = SaTokenUtil.getLoginAccount();
if (loginAccount == null || loginAccount.getTenantId() == null) {
throw new BusinessException("当前登录信息无效");
}
Map<BigInteger, Plugin> plugins = new HashMap<>();
for (PluginItem pluginItem : lockedPluginItems) {
if (pluginItem.getPluginId() == null) {
throw new BusinessException("插件工具关联的插件不存在");
}
Plugin plugin = plugins.get(pluginItem.getPluginId());
if (plugin == null) {
plugin = pluginService.getById(pluginItem.getPluginId());
if (plugin == null || plugin.getTenantId() == null
|| !loginAccount.getTenantId().toString().equals(plugin.getTenantId().toString())) {
throw new BusinessException("无权限删除该插件工具");
}
pluginVisibilityService.assertPluginVisible(
plugin.getCreatedBy(),
plugin.getId(),
"无权限删除该插件工具"
);
plugins.put(pluginItem.getPluginId(), plugin);
}
if (PluginType.isWorkflow(plugin.getType())) {
return Result.fail(1, "工作流插件工具由系统自动维护,不支持删除"); return Result.fail(1, "工作流插件工具由系统自动维护,不支持删除");
} }
} }
}
agentResourceReferenceService.assertPluginItemsUnused(List.copyOf(uniquePluginItemIds));
return null; return null;
} }
} }

View File

@@ -45,6 +45,16 @@ public class ResourceController extends BaseCurdController<ResourceService, Reso
super(service); super(service);
} }
/**
* 获取资源列表关键字搜索字段。
*
* @return 资源名称属性
*/
@Override
protected String[] getKeywordSearchProperties() {
return new String[]{"resourceName"};
}
@Override @Override
protected Result<?> onSaveOrUpdateBefore(Resource entity, boolean isSave) { protected Result<?> onSaveOrUpdateBefore(Resource entity, boolean isSave) {
LoginAccount loginUser = SaTokenUtil.getLoginAccount(); LoginAccount loginUser = SaTokenUtil.getLoginAccount();

View File

@@ -1,11 +1,8 @@
package tech.easyflow.admin.controller.ai; package tech.easyflow.admin.controller.ai;
import cn.hutool.core.io.IoUtil; import cn.hutool.core.io.IoUtil;
import com.easyagents.core.model.embedding.EmbeddingModel;
import com.easyagents.core.store.DocumentStore;
import com.easyagents.core.store.StoreOptions;
import com.easyagents.core.store.StoreResult;
import com.mybatisflex.core.paginate.Page; import com.mybatisflex.core.paginate.Page;
import com.mybatisflex.core.query.QueryColumn;
import com.mybatisflex.core.query.QueryWrapper; import com.mybatisflex.core.query.QueryWrapper;
import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse; import jakarta.servlet.http.HttpServletResponse;
@@ -21,6 +18,11 @@ import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import org.springframework.web.multipart.MultipartFile; import org.springframework.web.multipart.MultipartFile;
import tech.easyflow.ai.documentimport.DocumentImportDtos; import tech.easyflow.ai.documentimport.DocumentImportDtos;
import tech.easyflow.ai.documentimport.task.DocumentImportTaskStatusStreamService; import tech.easyflow.ai.documentimport.task.DocumentImportTaskStatusStreamService;
import tech.easyflow.ai.dto.DocumentChunkContentUpdateRequest;
import tech.easyflow.ai.dto.DocumentChunkDeleteResult;
import tech.easyflow.ai.dto.DocumentChunkSyncRetryRequest;
import tech.easyflow.ai.dto.DocumentChunkSyncStatus;
import tech.easyflow.ai.dto.DocumentChunkSyncStatusRequest;
import tech.easyflow.ai.dto.KnowledgeShareLimitedConfigRequest; import tech.easyflow.ai.dto.KnowledgeShareLimitedConfigRequest;
import tech.easyflow.ai.dto.KnowledgeSearchResultItem; import tech.easyflow.ai.dto.KnowledgeSearchResultItem;
import tech.easyflow.ai.entity.Document; import tech.easyflow.ai.entity.Document;
@@ -46,6 +48,7 @@ import tech.easyflow.ai.vo.FaqImportResultVo;
import tech.easyflow.ai.vo.KnowledgeShareAuthContext; import tech.easyflow.ai.vo.KnowledgeShareAuthContext;
import tech.easyflow.ai.vo.KnowledgeShareViewDetail; import tech.easyflow.ai.vo.KnowledgeShareViewDetail;
import tech.easyflow.common.domain.Result; import tech.easyflow.common.domain.Result;
import tech.easyflow.common.util.SearchKeywordUtil;
import tech.easyflow.common.filestorage.FileStorageService; import tech.easyflow.common.filestorage.FileStorageService;
import tech.easyflow.common.vo.UploadResVo; import tech.easyflow.common.vo.UploadResVo;
import tech.easyflow.common.web.exceptions.BusinessException; import tech.easyflow.common.web.exceptions.BusinessException;
@@ -59,7 +62,6 @@ import java.net.URLEncoder;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.text.SimpleDateFormat; import java.text.SimpleDateFormat;
import java.util.Arrays; import java.util.Arrays;
import java.util.Collections;
import java.util.Date; import java.util.Date;
import java.util.HashMap; import java.util.HashMap;
import java.util.HashSet; import java.util.HashSet;
@@ -74,7 +76,7 @@ import java.util.Set;
@RequestMapping("/api/v1/share/knowledge") @RequestMapping("/api/v1/share/knowledge")
public class ShareKnowledgeController { public class ShareKnowledgeController {
private static final long MAX_IMAGE_SIZE_BYTES = 5L * 1024L * 1024L; private static final long MAX_IMAGE_SIZE_BYTES = 20L * 1024L * 1024L;
private static final Set<String> ALLOWED_IMAGE_TYPES = new HashSet<>(Arrays.asList( private static final Set<String> ALLOWED_IMAGE_TYPES = new HashSet<>(Arrays.asList(
"image/jpeg", "image/jpeg",
"image/png", "image/png",
@@ -502,39 +504,27 @@ public class ShareKnowledgeController {
@PostMapping("/documentChunk/update") @PostMapping("/documentChunk/update")
public Result<?> updateDocumentChunk( public Result<?> updateDocumentChunk(
@RequestParam String shareKey, @RequestParam String shareKey,
@JsonBody DocumentChunk documentChunk @JsonBody(required = true, skipConvertError = false)
DocumentChunkContentUpdateRequest request
) { ) {
KnowledgeShareAuthContext context = knowledgeShareService.assertUrlShareAccess( KnowledgeShareAuthContext context = knowledgeShareService.assertUrlShareAccess(
shareKey, shareKey,
null, null,
KnowledgeShareActionScope.CONTENT_UPDATE.name() KnowledgeShareActionScope.CONTENT_UPDATE.name()
); );
DocumentChunk current = documentChunkService.getById(documentChunk.getId()); DocumentChunk current = documentChunkService.getById(request.getId());
if (current == null || current.getDocumentCollectionId() == null if (current == null || current.getDocumentCollectionId() == null
|| current.getDocumentCollectionId().compareTo(context.getKnowledge().getId()) != 0) { || current.getDocumentCollectionId().compareTo(context.getKnowledge().getId()) != 0) {
throw new BusinessException("记录不存在"); throw new BusinessException("记录不存在");
} }
boolean success = documentChunkService.updateById(documentChunk); DocumentChunk updated = documentChunkService.updateContent(
if (success) { context.getKnowledge().getId(),
DocumentStore documentStore = context.getKnowledge().toDocumentStore(); current.getId(),
if (documentStore == null) { request.getContent()
return Result.fail(2, "知识库没有配置向量库"); );
}
Model model = modelService.getModelInstance(context.getKnowledge().getVectorEmbedModelId());
if (model == null) {
return Result.fail(3, "知识库没有配置向量模型");
}
EmbeddingModel embeddingModel = model.toEmbeddingModel();
documentStore.setEmbeddingModel(embeddingModel);
StoreOptions options = StoreOptions.ofCollectionName(context.getKnowledge().getVectorStoreCollection());
com.easyagents.core.document.Document doc = com.easyagents.core.document.Document.of(documentChunk.getContent());
doc.setId(documentChunk.getId());
StoreResult result = documentStore.update(doc, options);
audit(context, "更新分享文档 Chunk", "KNOWLEDGE_SHARE_URL_WRITE", true, audit(context, "更新分享文档 Chunk", "KNOWLEDGE_SHARE_URL_WRITE", true,
auditDetail("knowledgeId", context.getKnowledge().getId(), "chunkId", documentChunk.getId())); auditDetail("knowledgeId", context.getKnowledge().getId(), "chunkId", request.getId()));
return Result.ok(result); return Result.ok(updated);
}
return Result.ok(false);
} }
/** /**
@@ -555,21 +545,50 @@ public class ShareKnowledgeController {
|| current.getDocumentCollectionId().compareTo(context.getKnowledge().getId()) != 0) { || current.getDocumentCollectionId().compareTo(context.getKnowledge().getId()) != 0) {
return Result.fail(1, "记录不存在"); return Result.fail(1, "记录不存在");
} }
DocumentStore documentStore = context.getKnowledge().toDocumentStore(); DocumentChunkDeleteResult removed = documentChunkService.deleteChunk(
if (documentStore == null) { context.getKnowledge().getId(),
return Result.fail(2, "知识库没有配置向量库"); chunkId
} );
Model model = modelService.getModelInstance(context.getKnowledge().getVectorEmbedModelId());
if (model == null) {
return Result.fail(3, "知识库没有配置向量模型");
}
documentStore.setEmbeddingModel(model.toEmbeddingModel());
StoreOptions options = StoreOptions.ofCollectionName(context.getKnowledge().getVectorStoreCollection());
documentStore.delete(Collections.singletonList(chunkId), options);
documentChunkService.removeById(chunkId);
audit(context, "删除分享文档 Chunk", "KNOWLEDGE_SHARE_URL_WRITE", true, audit(context, "删除分享文档 Chunk", "KNOWLEDGE_SHARE_URL_WRITE", true,
auditDetail("knowledgeId", context.getKnowledge().getId(), "chunkId", chunkId)); auditDetail("knowledgeId", context.getKnowledge().getId(), "chunkId", chunkId));
return Result.ok(true); return Result.ok(removed);
}
@PostMapping("/documentChunk/syncStatus")
public Result<List<DocumentChunkSyncStatus>> documentChunkSyncStatus(
@RequestParam String shareKey,
@JsonBody DocumentChunkSyncStatusRequest request
) {
KnowledgeShareAuthContext context = knowledgeShareService.assertUrlShareAccess(
shareKey, null, KnowledgeShareActionScope.VIEW.name()
);
Document document = documentService.getById(request.getDocumentId());
if (document == null || document.getCollectionId() == null
|| document.getCollectionId().compareTo(context.getKnowledge().getId()) != 0) {
throw new BusinessException("文档不存在");
}
return Result.ok(documentChunkService.listIndexSyncStatus(
context.getKnowledge().getId(), request.getDocumentId(), request.getIds()
));
}
@PostMapping("/documentChunk/retrySync")
public Result<DocumentChunk> retryDocumentChunkSync(
@RequestParam String shareKey,
@JsonBody DocumentChunkSyncRetryRequest request
) {
KnowledgeShareAuthContext context = knowledgeShareService.assertUrlShareAccess(
shareKey, null, KnowledgeShareActionScope.CONTENT_UPDATE.name()
);
if (request.getIndexSyncVersion() == null) {
throw new BusinessException("同步版本不能为空");
}
DocumentChunk chunk = documentChunkService.retryIndexSync(
context.getKnowledge().getId(), request.getId(), request.getIndexSyncVersion()
);
audit(context, "重试分享文档 Chunk 索引同步", "KNOWLEDGE_SHARE_URL_WRITE", true,
auditDetail("knowledgeId", context.getKnowledge().getId(), "chunkId", request.getId()));
return Result.ok(chunk);
} }
/** /**
@@ -659,9 +678,15 @@ public class ShareKnowledgeController {
faqCategoryService.ensureDefaultCategory(context.getKnowledge().getId()); faqCategoryService.ensureDefaultCategory(context.getKnowledge().getId());
QueryWrapper queryWrapper = QueryWrapper.create() QueryWrapper queryWrapper = QueryWrapper.create()
.eq(FaqItem::getCollectionId, context.getKnowledge().getId()); .eq(FaqItem::getCollectionId, context.getKnowledge().getId());
String keyword = request.getParameter("keyword");
String question = request.getParameter("question"); String question = request.getParameter("question");
if (StringUtils.hasText(question)) { if (StringUtils.hasText(keyword)) {
queryWrapper.like(FaqItem::getQuestion, question.trim()); String pattern = SearchKeywordUtil.literalContainsPattern(keyword);
queryWrapper.and(new QueryColumn("question").likeRaw(pattern)
.or(new QueryColumn("answer_text").likeRaw(pattern)));
} else if (StringUtils.hasText(question)) {
queryWrapper.and(new QueryColumn("question")
.likeRaw(SearchKeywordUtil.literalContainsPattern(question)));
} }
String categoryId = request.getParameter("categoryId"); String categoryId = request.getParameter("categoryId");
if (StringUtils.hasText(categoryId)) { if (StringUtils.hasText(categoryId)) {
@@ -776,7 +801,7 @@ public class ShareKnowledgeController {
throw new BusinessException("图片不能为空"); throw new BusinessException("图片不能为空");
} }
if (file.getSize() > MAX_IMAGE_SIZE_BYTES) { if (file.getSize() > MAX_IMAGE_SIZE_BYTES) {
throw new BusinessException("图片大小不能超过5MB"); throw new BusinessException("图片大小不能超过20MB");
} }
if (!isAllowedImageType(file)) { if (!isAllowedImageType(file)) {
throw new BusinessException("仅支持 JPG/PNG/WEBP/GIF 图片"); throw new BusinessException("仅支持 JPG/PNG/WEBP/GIF 图片");

View File

@@ -1,88 +1,45 @@
package tech.easyflow.admin.controller.ai; package tech.easyflow.admin.controller.ai;
import cn.hutool.core.util.IdUtil; import cn.dev33.satoken.annotation.SaCheckPermission;
import com.alibaba.fastjson2.JSON;
import com.alibaba.fastjson2.JSONArray;
import com.alibaba.fastjson2.JSONObject; import com.alibaba.fastjson2.JSONObject;
import com.easyagents.flow.core.chain.ChainDefinition;
import com.easyagents.flow.core.chain.Node;
import com.easyagents.flow.core.node.ConfirmNode;
import com.easyagents.flow.core.node.EndNode;
import com.easyagents.flow.core.node.StartNode;
import com.easyagents.flow.core.parser.ChainParser;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
import tech.easyflow.ai.easyagentsflow.service.WorkflowDatacenterContentService; import tech.easyflow.admin.service.ai.WorkflowDesignerOptionService;
import tech.easyflow.ai.entity.Workflow;
import tech.easyflow.ai.service.WorkflowService;
import tech.easyflow.common.domain.Result; import tech.easyflow.common.domain.Result;
import tech.easyflow.common.web.exceptions.BusinessException;
import javax.annotation.Resource; import java.math.BigInteger;
import java.util.List;
/**
* 工作流节点兼容接口。
*/
@RequestMapping("/api/v1/workflowNode") @RequestMapping("/api/v1/workflowNode")
@RestController @RestController
public class WorkFlowNodeController { public class WorkFlowNodeController {
@Resource private final WorkflowDesignerOptionService workflowDesignerOptionService;
private WorkflowService workflowService;
@Resource
private ChainParser chainParser;
@Resource
private WorkflowDatacenterContentService workflowDatacenterContentService;
/**
* 创建工作流节点兼容控制器。
*
* @param workflowDesignerOptionService 工作流设计器选项服务
*/
public WorkFlowNodeController(WorkflowDesignerOptionService workflowDesignerOptionService) {
this.workflowDesignerOptionService = workflowDesignerOptionService;
}
/**
* 查询子流程输入输出定义。
*
* @param currentId 当前工作流 ID
* @param workflowId 子流程 ID
* @return 子流程节点配置
* @deprecated 请使用 {@code /api/v1/workflow/designer/childWorkflow}
*/
@Deprecated
@GetMapping("/getChainParams") @GetMapping("/getChainParams")
public Result<?> getChainParams(String currentId, String workflowId) { @SaCheckPermission("/api/v1/workflow/query")
if (workflowId.equals(currentId)) { public Result<JSONObject> getChainParams(BigInteger currentId, BigInteger workflowId) {
throw new BusinessException("工作流不能作为自身子节点"); return Result.ok(workflowDesignerOptionService.getChildWorkflowNodeData(currentId, workflowId));
}
JSONObject nodeData = new JSONObject();
Workflow workflow = workflowService.getById(workflowId);
if (workflow == null) {
throw new BusinessException("工作流不存在: " + workflowId);
}
nodeData.put("workflowId", workflow.getId());
nodeData.put("workflowName", workflow.getTitle());
ChainDefinition definition = chainParser.parse(workflowDatacenterContentService.prepareContent(workflow.getContent()));
List<Node> nodes = definition.getNodes();
JSONArray inputs = new JSONArray();
JSONArray outputs = new JSONArray();
for (Node node : nodes) {
if (node instanceof StartNode) {
inputs = JSON.parseArray(JSON.toJSONString(node.getParameters()));
handleArray(inputs);
}
if (node instanceof EndNode) {
outputs = JSON.parseArray(JSON.toJSONString(((EndNode) node).getOutputDefs()));
handleArray(outputs);
}
if (node instanceof ConfirmNode) {
throw new BusinessException("工作流存在【确认节点】,暂不支持作为子节点");
}
}
nodeData.put("parameters", inputs);
nodeData.put("outputDefs", outputs);
return Result.ok(nodeData);
}
private void handleArray(JSONArray array) {
if (array != null) {
for (Object o : array) {
JSONObject obj = (JSONObject) o;
obj.put("id", IdUtil.simpleUUID());
obj.put("nameDisabled", true);
obj.put("dataTypeDisabled", true);
obj.put("deleteDisabled", true);
obj.put("addChildDisabled", true);
obj.put("refType", "ref");
JSONArray children = obj.getJSONArray("children");
if (children != null) {
handleArray(children);
}
}
}
} }
} }

View File

@@ -0,0 +1,344 @@
package tech.easyflow.admin.controller.ai;
import com.easyagents.flow.core.chain.runtime.ChainExecutor;
import com.mybatisflex.core.query.QueryWrapper;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import tech.easyflow.admin.service.ai.WorkflowChatEventStream;
import tech.easyflow.ai.easyagentsflow.entity.WorkflowCheckStage;
import tech.easyflow.ai.easyagentsflow.service.WorkflowCheckService;
import tech.easyflow.ai.easyagentsflow.service.WorkflowRunningParameterResolver;
import tech.easyflow.ai.easyagentsflow.service.WorkflowResumeService;
import tech.easyflow.ai.easyagentsflow.support.PublishedWorkflowDefinitionIds;
import tech.easyflow.ai.entity.Workflow;
import tech.easyflow.ai.entity.WorkflowExecResult;
import tech.easyflow.ai.entity.WorkflowExecStep;
import tech.easyflow.ai.enums.PublishStatus;
import tech.easyflow.ai.service.WorkflowExecResultService;
import tech.easyflow.ai.service.WorkflowExecStepService;
import tech.easyflow.ai.service.WorkflowService;
import tech.easyflow.ai.service.WorkflowShareService;
import tech.easyflow.ai.share.WorkflowSharePolicy;
import tech.easyflow.ai.utils.WorkFlowUtil;
import tech.easyflow.common.constant.Constants;
import tech.easyflow.common.domain.Result;
import tech.easyflow.common.entity.LoginAccount;
import tech.easyflow.common.satoken.util.SaTokenUtil;
import tech.easyflow.common.web.exceptions.BusinessException;
import tech.easyflow.common.web.jsonbody.JsonBody;
import tech.easyflow.system.enums.CategoryResourceType;
import tech.easyflow.system.enums.ResourceAction;
import tech.easyflow.system.service.ResourceAccessService;
import javax.annotation.Resource;
import java.math.BigInteger;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* 工作流管理端与分享端的对话运行接口。
*/
@RestController
@RequestMapping("/api/v1/workflowChat")
public class WorkflowChatController {
@Resource
private WorkflowService workflowService;
@Resource
private WorkflowShareService workflowShareService;
@Resource
private WorkflowCheckService workflowCheckService;
@Resource
private WorkflowRunningParameterResolver parameterResolver;
@Resource
private ResourceAccessService resourceAccessService;
@Resource
private WorkflowChatEventStream eventStream;
@Resource
private ChainExecutor chainExecutor;
@Resource
private WorkflowResumeService workflowResumeService;
@Resource
private WorkflowExecResultService execResultService;
@Resource
private WorkflowExecStepService execStepService;
/**
* 获取工作流的对话运行描述和输入表单。
*
* @param workflowId 工作流 ID
* @param request HTTP 请求
* @return 对话运行描述
*/
@GetMapping("/descriptor")
public Result<Map<String, Object>> descriptor(
BigInteger workflowId,
HttpServletRequest request
) {
Workflow workflow = loadRunnableWorkflow(workflowId, request);
workflowCheckService.checkOrThrow(
workflow.getContent(),
WorkflowCheckStage.PRE_EXECUTE,
workflow.getId()
);
Map<String, Object> descriptor =
parameterResolver.buildRunningParametersView(workflow);
if (descriptor == null) {
throw new BusinessException("工作流输入配置无法解析");
}
descriptor.put("workflowId", workflow.getId());
descriptor.put("publishStatus", workflow.getPublishStatus());
descriptor.put("shareable", isStrictlyPublished(workflow));
return Result.ok(descriptor);
}
/**
* 启动工作流,并流式返回全部可见输出。
*
* @param workflowId 工作流 ID
* @param variables 工作流运行变量
* @param request HTTP 请求
* @return 工作流 SSE 事件流
*/
@PostMapping(
value = "/run",
produces = MediaType.TEXT_EVENT_STREAM_VALUE
)
public SseEmitter run(
@JsonBody(value = "workflowId", required = true)
BigInteger workflowId,
@JsonBody("variables") Map<String, Object> variables,
HttpServletRequest request
) {
Workflow workflow = loadRunnableWorkflow(workflowId, request);
workflowCheckService.checkOrThrow(
workflow.getContent(),
WorkflowCheckStage.PRE_EXECUTE,
workflow.getId()
);
Map<String, Object> normalizedVariables =
parameterResolver.normalizeRuntimeVariables(
workflow.getContent(),
variables
);
LoginAccount account = SaTokenUtil.getLoginAccount();
normalizedVariables.put(Constants.LOGIN_USER_KEY, account);
normalizedVariables.put(
WorkFlowUtil.CREATED_KEY_MEMORY_KEY,
hasChatShareKey(request)
? WorkFlowUtil.WORKFLOW_CHAT_SHARE
: WorkFlowUtil.WORKFLOW_CHAT
);
return eventStream.start(
isStrictlyPublished(workflow)
? PublishedWorkflowDefinitionIds.published(workflowId.toString())
: workflowId.toString(),
normalizedVariables
);
}
/**
* 取消当前用户发起的工作流执行。
*
* @param executeId 执行实例 ID
* @return 是否完成取消状态转换
*/
@PostMapping("/cancel")
public Result<Boolean> cancel(
@JsonBody(value = "executeId", required = true)
String executeId
) {
assertExecutionOwnership(executeId);
return Result.ok(chainExecutor.cancel(executeId, "用户已中止运行"));
}
/**
* 恢复当前用户发起并等待确认的工作流执行。
*
* @param executeId 执行实例 ID
* @param confirmParams 确认参数
* @return 空结果
*/
@PostMapping("/resume")
public Result<Void> resume(
@JsonBody(value = "executeId", required = true)
String executeId,
@JsonBody("confirmParams")
Map<String, Object> confirmParams
) {
assertExecutionOwnership(executeId);
workflowResumeService.resume(executeId, confirmParams);
return Result.ok();
}
/**
* 获取当前用户工作流执行的运行详情。
*
* @param executeId 执行实例 ID
* @return 执行记录和有序节点步骤
*/
@GetMapping("/execution")
public Result<Map<String, Object>> detail(String executeId) {
WorkflowExecResult record = assertExecutionOwnership(executeId);
List<WorkflowExecStep> steps = execStepService.list(
QueryWrapper.create()
.eq(WorkflowExecStep::getRecordId, record.getId())
.orderBy(WorkflowExecStep::getStartTime, true)
);
List<Map<String, Object>> stepViews = new ArrayList<>(steps.size());
for (WorkflowExecStep step : steps) {
Map<String, Object> view = new LinkedHashMap<>();
view.put("id", step.getId());
view.put("attemptKey", step.getExecKey());
view.put("nodeId", step.getNodeId());
view.put("nodeName", step.getNodeName());
view.put("input", step.getInput());
view.put("output", step.getOutput());
view.put("status", step.getStatus());
view.put("errorInfo", step.getErrorInfo());
view.put("startTime", step.getStartTime());
view.put("endTime", step.getEndTime());
view.put("execTime", step.getExecTime());
stepViews.add(view);
}
Map<String, Object> recordView = new LinkedHashMap<>();
recordView.put("executeId", record.getExecKey());
recordView.put("workflowId", record.getWorkflowId());
recordView.put("title", record.getTitle());
recordView.put("status", record.getStatus());
recordView.put("input", record.getInput());
recordView.put("output", record.getOutput());
recordView.put("errorInfo", record.getErrorInfo());
recordView.put("startTime", record.getStartTime());
recordView.put("endTime", record.getEndTime());
recordView.put("execTime", record.getExecTime());
Map<String, Object> detail = new LinkedHashMap<>();
detail.put("record", recordView);
detail.put("steps", stepViews);
return Result.ok(detail);
}
/**
* 加载可运行工作流,并校验直接访问或对话分享权限。
* 管理端直接运行时,已发布工作流使用发布快照,未发布工作流使用当前内容;
* 分享运行始终要求严格发布快照。
*
* @param workflowId 工作流 ID
* @param request HTTP 请求
* @return 可运行工作流视图
*/
private Workflow loadRunnableWorkflow(
BigInteger workflowId,
HttpServletRequest request
) {
if (workflowId == null) {
throw new BusinessException("工作流ID不能为空");
}
LoginAccount account = SaTokenUtil.getLoginAccount();
Workflow current = workflowService.getById(workflowId);
if (current == null) {
throw new BusinessException("工作流不存在");
}
boolean sharedRequest = hasChatShareKey(request);
if (sharedRequest) {
workflowShareService.assertChatShareAccess(
request.getHeader(
WorkflowSharePolicy.CHAT_SHARE_KEY_HEADER
),
workflowId,
account.getTenantId()
);
} else {
resourceAccessService.assertAccess(
CategoryResourceType.WORKFLOW,
current,
ResourceAction.USE,
"无权限运行工作流"
);
}
Workflow published = workflowService.getPublishedById(workflowId);
if (isStrictlyPublished(published)) {
return published;
}
if (sharedRequest) {
throw new BusinessException(
409,
409,
"工作流尚未发布或已下线"
);
}
return current;
}
/**
* 校验执行记录属于当前用户发起的工作流对话。
*
* @param executeId 执行实例 ID
* @return 执行记录
*/
private WorkflowExecResult assertExecutionOwnership(String executeId) {
if (executeId == null || executeId.isBlank()) {
throw new BusinessException("执行ID不能为空");
}
WorkflowExecResult record = execResultService.getByExecKey(executeId);
if (record == null) {
throw new BusinessException("工作流执行记录不存在,请稍后重试");
}
LoginAccount account = SaTokenUtil.getLoginAccount();
boolean chatSource = WorkFlowUtil.WORKFLOW_CHAT.equals(
record.getCreatedKey()
) || WorkFlowUtil.WORKFLOW_CHAT_SHARE.equals(record.getCreatedKey());
if (!chatSource
|| account.getId() == null
|| !account.getId().toString().equals(
record.getCreatedBy()
)) {
throw new BusinessException(
403,
403,
"无权限访问当前工作流执行记录"
);
}
return record;
}
/**
* 判断请求是否携带对话分享密钥。
*
* @param request HTTP 请求
* @return 携带非空对话分享密钥时返回 {@code true}
*/
private boolean hasChatShareKey(HttpServletRequest request) {
String shareKey = request == null
? null
: request.getHeader(
WorkflowSharePolicy.CHAT_SHARE_KEY_HEADER
);
return shareKey != null && !shareKey.isBlank();
}
/**
* 判断工作流是否可按严格发布快照运行。
*
* @param workflow 工作流
* @return 已发布且存在快照时返回 {@code true}
*/
private boolean isStrictlyPublished(Workflow workflow) {
return workflow != null
&& PublishStatus.PUBLISHED.getCode().equals(
workflow.getPublishStatus()
)
&& workflow.getPublishedSnapshotJson() != null
&& !workflow.getPublishedSnapshotJson().isEmpty();
}
}

View File

@@ -4,15 +4,23 @@ import cn.dev33.satoken.annotation.SaCheckPermission;
import cn.dev33.satoken.stp.StpUtil; import cn.dev33.satoken.stp.StpUtil;
import cn.hutool.core.io.IoUtil; import cn.hutool.core.io.IoUtil;
import cn.hutool.core.util.IdUtil; import cn.hutool.core.util.IdUtil;
import com.alibaba.fastjson2.JSONObject;
import com.mybatisflex.core.paginate.Page; import com.mybatisflex.core.paginate.Page;
import com.easyagents.flow.core.chain.runtime.ChainExecutor; import com.easyagents.flow.core.chain.runtime.ChainExecutor;
import com.mybatisflex.core.query.QueryWrapper; import com.mybatisflex.core.query.QueryWrapper;
import org.springframework.util.StringUtils; import org.springframework.util.StringUtils;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.context.request.RequestContextHolder; import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes; import org.springframework.web.context.request.ServletRequestAttributes;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile; import org.springframework.web.multipart.MultipartFile;
import tech.easyflow.admin.controller.ai.support.AiResourceCreatorNameSupport; import tech.easyflow.admin.controller.ai.support.AiResourceCreatorNameSupport;
import tech.easyflow.admin.model.ai.WorkflowDesignerOptionsView;
import tech.easyflow.admin.service.ai.WorkflowDesignerOptionService;
import tech.easyflow.agent.entity.AgentToolBinding;
import tech.easyflow.agent.enums.AgentToolType;
import tech.easyflow.agent.service.AgentToolBindingService;
import tech.easyflow.ai.permission.WorkflowShareResourceAccessGrantProvider;
import tech.easyflow.ai.permission.WorkflowVisibilityQueryHelper; import tech.easyflow.ai.permission.WorkflowVisibilityQueryHelper;
import tech.easyflow.ai.easyagentsflow.entity.ChainInfo; import tech.easyflow.ai.easyagentsflow.entity.ChainInfo;
import tech.easyflow.ai.easyagentsflow.entity.NodeInfo; import tech.easyflow.ai.easyagentsflow.entity.NodeInfo;
@@ -23,13 +31,13 @@ import tech.easyflow.ai.easyagentsflow.service.TinyFlowService;
import tech.easyflow.ai.easyagentsflow.service.WorkflowCheckService; import tech.easyflow.ai.easyagentsflow.service.WorkflowCheckService;
import tech.easyflow.ai.easyagentsflow.service.WorkflowDatacenterContentService; import tech.easyflow.ai.easyagentsflow.service.WorkflowDatacenterContentService;
import tech.easyflow.ai.easyagentsflow.service.WorkflowRunningParameterResolver; import tech.easyflow.ai.easyagentsflow.service.WorkflowRunningParameterResolver;
import tech.easyflow.ai.easyagentsflow.service.WorkflowResumeService;
import tech.easyflow.ai.entity.Workflow; import tech.easyflow.ai.entity.Workflow;
import tech.easyflow.ai.enums.PublishStatus; import tech.easyflow.ai.enums.PublishStatus;
import tech.easyflow.ai.publish.WorkflowPublishAppService; import tech.easyflow.ai.publish.WorkflowPublishAppService;
import tech.easyflow.ai.service.AiResourceApprovalStateService; import tech.easyflow.ai.service.AiResourceApprovalStateService;
import tech.easyflow.ai.vo.OfflineImpactCheckVo; import tech.easyflow.ai.vo.OfflineImpactCheckVo;
import tech.easyflow.approval.entity.vo.ApprovalActionResult; import tech.easyflow.approval.entity.vo.ApprovalActionResult;
import tech.easyflow.ai.service.BotWorkflowService;
import tech.easyflow.ai.service.ModelService; import tech.easyflow.ai.service.ModelService;
import tech.easyflow.ai.service.WorkflowService; import tech.easyflow.ai.service.WorkflowService;
import tech.easyflow.common.constant.Constants; import tech.easyflow.common.constant.Constants;
@@ -39,6 +47,7 @@ import tech.easyflow.common.satoken.util.SaTokenUtil;
import tech.easyflow.common.web.controller.BaseCurdController; import tech.easyflow.common.web.controller.BaseCurdController;
import tech.easyflow.common.web.exceptions.BusinessException; import tech.easyflow.common.web.exceptions.BusinessException;
import tech.easyflow.common.web.jsonbody.JsonBody; import tech.easyflow.common.web.jsonbody.JsonBody;
import tech.easyflow.datacenter.execution.model.DatasetRef;
import tech.easyflow.system.enums.CategoryResourceType; import tech.easyflow.system.enums.CategoryResourceType;
import tech.easyflow.system.enums.ResourceAction; import tech.easyflow.system.enums.ResourceAction;
import tech.easyflow.system.enums.ResourceLookup; import tech.easyflow.system.enums.ResourceLookup;
@@ -53,6 +62,7 @@ import java.io.Serializable;
import java.math.BigInteger; import java.math.BigInteger;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.util.Collection; import java.util.Collection;
import java.util.Date;
import java.util.HashMap; import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
@@ -71,7 +81,7 @@ public class WorkflowController extends BaseCurdController<WorkflowService, Work
@Resource @Resource
private SysApiKeyService apiKeyService; private SysApiKeyService apiKeyService;
@Resource @Resource
private BotWorkflowService botWorkflowService; private AgentToolBindingService agentToolBindingService;
@Resource @Resource
private ChainExecutor chainExecutor; private ChainExecutor chainExecutor;
@Resource @Resource
@@ -85,6 +95,8 @@ public class WorkflowController extends BaseCurdController<WorkflowService, Work
@Resource @Resource
private WorkflowRunningParameterResolver workflowRunningParameterResolver; private WorkflowRunningParameterResolver workflowRunningParameterResolver;
@Resource @Resource
private WorkflowResumeService workflowResumeService;
@Resource
private ResourceAccessService resourceAccessService; private ResourceAccessService resourceAccessService;
@Resource @Resource
private WorkflowVisibilityQueryHelper workflowVisibilityQueryHelper; private WorkflowVisibilityQueryHelper workflowVisibilityQueryHelper;
@@ -94,12 +106,130 @@ public class WorkflowController extends BaseCurdController<WorkflowService, Work
private AiResourceApprovalStateService aiResourceApprovalStateService; private AiResourceApprovalStateService aiResourceApprovalStateService;
@Resource @Resource
private AiResourceCreatorNameSupport aiResourceCreatorNameSupport; private AiResourceCreatorNameSupport aiResourceCreatorNameSupport;
@Resource
private WorkflowShareResourceAccessGrantProvider workflowShareGrantProvider;
@Resource
private WorkflowDesignerOptionService workflowDesignerOptionService;
public WorkflowController(WorkflowService service, ModelService modelService) { public WorkflowController(WorkflowService service, ModelService modelService) {
super(service); super(service);
this.modelService = modelService; this.modelService = modelService;
} }
/**
* 获取工作流列表关键字搜索字段。
*
* @return 标题和描述属性
*/
@Override
protected String[] getKeywordSearchProperties() {
return new String[]{"title", "description"};
}
/**
* 查询工作流设计器初始化所需的安全选项。
*
* @return 模型、知识库和代码引擎选项
*/
@GetMapping("/designer/options")
@SaCheckPermission("/api/v1/workflow/query")
public Result<WorkflowDesignerOptionsView> designerOptions() {
return Result.ok(workflowDesignerOptionService.listOptions(
codeEngineCapabilityService.listSupportedCodeEngines()));
}
/**
* 分页查询工作流设计器可用插件。
*
* @param pageNumber 页码
* @param pageSize 每页数量
* @param keyword 插件名称或描述关键字
* @return 插件安全选项分页
*/
@GetMapping("/designer/plugins")
@SaCheckPermission("/api/v1/workflow/query")
public Result<Page<WorkflowDesignerOptionsView.PluginOption>> designerPlugins(
Long pageNumber,
Long pageSize,
String keyword) {
return Result.ok(workflowDesignerOptionService.pagePlugins(pageNumber, pageSize, keyword));
}
/**
* 查询工作流插件节点配置。
*
* @param id 插件工具 ID
* @return 插件节点配置
*/
@GetMapping("/designer/pluginTinyFlow")
@SaCheckPermission("/api/v1/workflow/query")
public Result<?> designerPluginTinyFlow(BigInteger id) {
return Result.ok(workflowDesignerOptionService.getPluginTinyFlowData(id));
}
/**
* 查询工作流设计器的子流程节点配置。
*
* @param currentId 当前工作流 ID
* @param workflowId 子流程 ID
* @return 子流程输入输出定义
*/
@GetMapping("/designer/childWorkflow")
@SaCheckPermission("/api/v1/workflow/query")
public Result<JSONObject> designerChildWorkflow(BigInteger currentId, BigInteger workflowId) {
return Result.ok(workflowDesignerOptionService.getChildWorkflowNodeData(currentId, workflowId));
}
/**
* 查询工作流数据节点可见的数据源。
*
* @return 数据源安全选项
*/
@GetMapping("/designer/dataSources")
@SaCheckPermission("/api/v1/workflow/query")
public Result<List<WorkflowDesignerOptionsView.DataSourceOption>> designerDataSources() {
return Result.ok(workflowDesignerOptionService.listDataSources());
}
/**
* 查询工作流数据节点的数据目录。
*
* @param sourceId 数据源 ID
* @return 目录安全选项
*/
@GetMapping("/designer/catalogs")
@SaCheckPermission("/api/v1/workflow/query")
public Result<List<WorkflowDesignerOptionsView.CatalogOption>> designerCatalogs(BigInteger sourceId) {
return Result.ok(workflowDesignerOptionService.listCatalogs(sourceId));
}
/**
* 查询工作流数据节点的已接入数据集。
*
* @param sourceId 数据源 ID
* @param catalogId 目录 ID
* @return 数据集安全选项
*/
@GetMapping("/designer/managedTables")
@SaCheckPermission("/api/v1/workflow/query")
public Result<List<WorkflowDesignerOptionsView.DatasetOption>> designerManagedTables(
BigInteger sourceId,
BigInteger catalogId) {
return Result.ok(workflowDesignerOptionService.listManagedDatasets(sourceId, catalogId));
}
/**
* 查询工作流数据节点的数据集结构。
*
* @param datasetRef 数据集引用
* @return 数据集结构安全视图
*/
@GetMapping("/designer/schema")
@SaCheckPermission("/api/v1/workflow/query")
public Result<WorkflowDesignerOptionsView.DatasetSchemaOption> designerSchema(DatasetRef datasetRef) {
return Result.ok(workflowDesignerOptionService.getDatasetSchema(datasetRef));
}
/** /**
* 节点单独运行 * 节点单独运行
*/ */
@@ -121,6 +251,9 @@ public class WorkflowController extends BaseCurdController<WorkflowService, Work
if (workflow == null) { if (workflow == null) {
return Result.fail(1, "工作流不存在"); return Result.fail(1, "工作流不存在");
} }
workflowCheckService.checkOrThrow(
workflow.getContent(), WorkflowCheckStage.PRE_EXECUTE, workflow.getId());
workflowDesignerOptionService.assertContentReferences(workflow.getContent());
if (variables == null) { if (variables == null) {
variables = new HashMap<>(); variables = new HashMap<>();
} }
@@ -154,6 +287,7 @@ public class WorkflowController extends BaseCurdController<WorkflowService, Work
throw new RuntimeException("工作流不存在"); throw new RuntimeException("工作流不存在");
} }
workflowCheckService.checkOrThrow(workflow.getContent(), WorkflowCheckStage.PRE_EXECUTE, workflow.getId()); workflowCheckService.checkOrThrow(workflow.getContent(), WorkflowCheckStage.PRE_EXECUTE, workflow.getId());
workflowDesignerOptionService.assertContentReferences(workflow.getContent());
variables = workflowRunningParameterResolver.normalizeRuntimeVariables(workflow.getContent(), variables); variables = workflowRunningParameterResolver.normalizeRuntimeVariables(workflow.getContent(), variables);
if (StpUtil.isLogin()) { if (StpUtil.isLogin()) {
variables.put(Constants.LOGIN_USER_KEY, SaTokenUtil.getLoginAccount()); variables.put(Constants.LOGIN_USER_KEY, SaTokenUtil.getLoginAccount());
@@ -193,7 +327,7 @@ public class WorkflowController extends BaseCurdController<WorkflowService, Work
) )
public Result<Void> resume(@JsonBody(value = "executeId", required = true) String executeId, public Result<Void> resume(@JsonBody(value = "executeId", required = true) String executeId,
@JsonBody("confirmParams") Map<String, Object> confirmParams) { @JsonBody("confirmParams") Map<String, Object> confirmParams) {
chainExecutor.resumeAsync(executeId, confirmParams); workflowResumeService.resume(executeId, confirmParams);
return Result.ok(); return Result.ok();
} }
@@ -245,6 +379,7 @@ public class WorkflowController extends BaseCurdController<WorkflowService, Work
return Result.fail(1, "can not find the workflow by id: " + id); return Result.fail(1, "can not find the workflow by id: " + id);
} }
workflowCheckService.checkOrThrow(workflow.getContent(), WorkflowCheckStage.PRE_EXECUTE, workflow.getId()); workflowCheckService.checkOrThrow(workflow.getContent(), WorkflowCheckStage.PRE_EXECUTE, workflow.getId());
workflowDesignerOptionService.assertContentReferences(workflow.getContent());
Map<String, Object> res = workflowRunningParameterResolver.buildRunningParametersView(workflow); Map<String, Object> res = workflowRunningParameterResolver.buildRunningParametersView(workflow);
if (res == null) { if (res == null) {
return Result.fail(2, "节点配置错误,请检查! "); return Result.fail(2, "节点配置错误,请检查! ");
@@ -256,18 +391,48 @@ public class WorkflowController extends BaseCurdController<WorkflowService, Work
* 提交发布审批。 * 提交发布审批。
* *
* @param id 工作流 ID * @param id 工作流 ID
* @param applicationReason 审批说明
* @return 审批实例 ID * @return 审批实例 ID
*/ */
@PostMapping("/submitPublishApproval") @PostMapping("/submitPublishApproval")
@SaCheckPermission("/api/v1/workflow/save") @SaCheckPermission("/api/v1/workflow/save")
public Result<BigInteger> submitPublishApproval(@JsonBody("id") BigInteger id) { @RequireResourceAccess(
resource = CategoryResourceType.WORKFLOW,
action = ResourceAction.MANAGE,
lookup = ResourceLookup.WORKFLOW_ID,
idExpr = "#id",
denyMessage = "无权限发布工作流"
)
public Result<BigInteger> submitPublishApproval(
@JsonBody("id") BigInteger id,
@JsonBody("applicationReason") String applicationReason
) {
return buildApprovalActionResult( return buildApprovalActionResult(
workflowPublishAppService.submitPublishApproval(id), workflowPublishAppService.submitPublishApproval(id, applicationReason),
"已提交发布审批", "已提交发布审批",
"已直接发布" "已直接发布"
); );
} }
/**
* 预检工作流发布是否命中审批流。
*
* @param id 工作流 ID
* @return 是否需要审批
*/
@GetMapping("/publishApprovalRequirement")
@SaCheckPermission("/api/v1/workflow/save")
@RequireResourceAccess(
resource = CategoryResourceType.WORKFLOW,
action = ResourceAction.MANAGE,
lookup = ResourceLookup.WORKFLOW_ID,
idExpr = "#id",
denyMessage = "无权限发布工作流"
)
public Result<Boolean> publishApprovalRequirement(@RequestParam BigInteger id) {
return Result.ok(workflowPublishAppService.isPublishApprovalRequired(id));
}
/** /**
* 提交下线审批。 * 提交下线审批。
* *
@@ -276,6 +441,13 @@ public class WorkflowController extends BaseCurdController<WorkflowService, Work
*/ */
@PostMapping("/submitOfflineApproval") @PostMapping("/submitOfflineApproval")
@SaCheckPermission("/api/v1/workflow/save") @SaCheckPermission("/api/v1/workflow/save")
@RequireResourceAccess(
resource = CategoryResourceType.WORKFLOW,
action = ResourceAction.MANAGE,
lookup = ResourceLookup.WORKFLOW_ID,
idExpr = "#id",
denyMessage = "无权限下线工作流"
)
public Result<BigInteger> submitOfflineApproval(@JsonBody("id") BigInteger id) { public Result<BigInteger> submitOfflineApproval(@JsonBody("id") BigInteger id) {
return buildApprovalActionResult( return buildApprovalActionResult(
workflowPublishAppService.submitOfflineApproval(id), workflowPublishAppService.submitOfflineApproval(id),
@@ -311,6 +483,13 @@ public class WorkflowController extends BaseCurdController<WorkflowService, Work
*/ */
@PostMapping("/submitDeleteApproval") @PostMapping("/submitDeleteApproval")
@SaCheckPermission("/api/v1/workflow/remove") @SaCheckPermission("/api/v1/workflow/remove")
@RequireResourceAccess(
resource = CategoryResourceType.WORKFLOW,
action = ResourceAction.MANAGE,
lookup = ResourceLookup.WORKFLOW_ID,
idExpr = "#id",
denyMessage = "无权限删除工作流"
)
public Result<BigInteger> submitDeleteApproval(@JsonBody("id") BigInteger id) { public Result<BigInteger> submitDeleteApproval(@JsonBody("id") BigInteger id) {
return buildApprovalActionResult( return buildApprovalActionResult(
workflowPublishAppService.submitDeleteApproval(id), workflowPublishAppService.submitDeleteApproval(id),
@@ -378,20 +557,76 @@ public class WorkflowController extends BaseCurdController<WorkflowService, Work
} }
workflow.setId(null); workflow.setId(null);
workflow.setAlias(IdUtil.fastSimpleUUID()); workflow.setAlias(IdUtil.fastSimpleUUID());
workflow.setRevision(0);
commonFiled(workflow, account.getId(), account.getTenantId(), account.getDeptId()); commonFiled(workflow, account.getId(), account.getTenantId(), account.getDeptId());
workflowDesignerOptionService.assertContentReferences(workflow.getContent());
service.save(workflow); service.save(workflow);
return Result.ok(); return Result.ok();
} }
/**
* 更新工作流;分享访问只允许按修订号更新设计内容。
*
* @param entity 工作流更新内容
* @return 更新结果
*/
@Override
@PostMapping("update")
@Transactional(rollbackFor = Exception.class)
public Result<?> update(@JsonBody Workflow entity) {
if (entity == null || entity.getId() == null) {
throw new BusinessException("工作流 ID 不能为空");
}
LoginAccount loginAccount = SaTokenUtil.getLoginAccount();
boolean sharedRequest = workflowShareGrantProvider.isSharedRequestFor(
entity.getId(),
loginAccount
);
if (sharedRequest && entity.getContent() == null) {
throw new BusinessException(403, 403, "分享链接仅允许编辑工作流内容");
}
if (entity.getContent() == null) {
return super.update(entity);
}
if (entity.getRevision() == null) {
throw workflowRevisionConflict();
}
Result<?> beforeResult = onSaveOrUpdateBefore(entity, false);
if (beforeResult != null) {
return beforeResult;
}
boolean updated = service.updateContentByRevision(
entity.getId(),
entity.getContent(),
entity.getRevision(),
new Date(),
loginAccount.getId()
);
if (!updated) {
throw workflowRevisionConflict();
}
entity.setRevision(entity.getRevision() + 1);
if (!sharedRequest) {
entity.setContent(null);
service.updateById(entity);
}
onSaveOrUpdateAfter(entity, false);
return Result.ok(Map.of("revision", entity.getRevision()));
}
@Override @Override
protected Result onSaveOrUpdateBefore(Workflow entity, boolean isSave) { protected Result onSaveOrUpdateBefore(Workflow entity, boolean isSave) {
normalizeVisibilityScope(entity, isSave); normalizeVisibilityScope(entity, isSave);
if (isSave && entity.getRevision() == null) {
entity.setRevision(0);
}
if (!isSave && entity.getId() != null) { if (!isSave && entity.getId() != null) {
Workflow existed = requireWorkflow(String.valueOf(entity.getId())); Workflow existed = requireWorkflow(String.valueOf(entity.getId()));
resourceAccessService.assertAccess(CategoryResourceType.WORKFLOW, existed, ResourceAction.MANAGE, "无权限管理工作流"); resourceAccessService.assertAccess(CategoryResourceType.WORKFLOW, existed, ResourceAction.MANAGE, "无权限管理工作流");
} }
if (StringUtils.hasLength(entity.getContent())) { if (StringUtils.hasLength(entity.getContent())) {
workflowCheckService.checkOrThrow(entity.getContent(), WorkflowCheckStage.SAVE, entity.getId()); workflowCheckService.checkOrThrow(entity.getContent(), WorkflowCheckStage.SAVE, entity.getId());
workflowDesignerOptionService.assertContentReferences(entity.getContent());
} }
String alias = entity.getAlias(); String alias = entity.getAlias();
@@ -455,11 +690,12 @@ public class WorkflowController extends BaseCurdController<WorkflowService, Work
Workflow workflow = requireWorkflow(String.valueOf(id)); Workflow workflow = requireWorkflow(String.valueOf(id));
resourceAccessService.assertAccess(CategoryResourceType.WORKFLOW, workflow, ResourceAction.MANAGE, "无权限管理工作流"); resourceAccessService.assertAccess(CategoryResourceType.WORKFLOW, workflow, ResourceAction.MANAGE, "无权限管理工作流");
} }
QueryWrapper queryWrapper = QueryWrapper.create(); QueryWrapper queryWrapper = QueryWrapper.create()
queryWrapper.in("workflow_id", ids); .eq(AgentToolBinding::getToolType, AgentToolType.WORKFLOW.name())
boolean exists = botWorkflowService.exists(queryWrapper); .in(AgentToolBinding::getTargetId, ids);
boolean exists = agentToolBindingService.exists(queryWrapper);
if (exists) { if (exists) {
return Result.fail(1, "此工作流还关联有bot,请先取消关联后再删除"); return Result.fail(1, "此工作流仍被智能体使用,请先取消绑定后再删除");
} }
return null; return null;
} }
@@ -485,6 +721,15 @@ public class WorkflowController extends BaseCurdController<WorkflowService, Work
return workflow; return workflow;
} }
/**
* 构建工作流内容修订冲突异常。
*
* @return HTTP 409 业务异常
*/
private BusinessException workflowRevisionConflict() {
return new BusinessException(409, 409, "工作流已被其他人更新,请刷新后重新编辑");
}
private void applyPublishedOnlyFilter(QueryWrapper queryWrapper) { private void applyPublishedOnlyFilter(QueryWrapper queryWrapper) {
ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes(); ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
if (attributes == null) { if (attributes == null) {

View File

@@ -17,6 +17,7 @@ import tech.easyflow.common.domain.Result;
import tech.easyflow.common.entity.LoginAccount; import tech.easyflow.common.entity.LoginAccount;
import tech.easyflow.common.satoken.util.SaTokenUtil; import tech.easyflow.common.satoken.util.SaTokenUtil;
import tech.easyflow.common.web.controller.BaseCurdController; import tech.easyflow.common.web.controller.BaseCurdController;
import tech.easyflow.log.annotation.LogRecord;
import javax.annotation.Resource; import javax.annotation.Resource;
import java.math.BigInteger; import java.math.BigInteger;
@@ -36,9 +37,20 @@ public class WorkflowExecResultController extends BaseCurdController<WorkflowExe
super(service); super(service);
} }
/**
* 获取工作流执行记录关键字搜索字段。
*
* @return 执行 Key 属性
*/
@Override
protected String[] getKeywordSearchProperties() {
return new String[]{"execKey"};
}
@GetMapping("/del") @GetMapping("/del")
@Transactional(rollbackFor = Exception.class) @Transactional(rollbackFor = Exception.class)
@SaCheckPermission("/api/v1/workflow/remove") @SaCheckPermission("/api/v1/workflow/remove")
@LogRecord("删除工作流执行记录")
public Result<Void> del(BigInteger id) { public Result<Void> del(BigInteger id) {
LoginAccount account = SaTokenUtil.getLoginAccount(); LoginAccount account = SaTokenUtil.getLoginAccount();
WorkflowExecResult record = service.getById(id); WorkflowExecResult record = service.getById(id);

View File

@@ -37,6 +37,16 @@ public class WorkflowExecStepController extends BaseCurdController<WorkflowExecS
super(service); super(service);
} }
/**
* 获取执行步骤关键字搜索字段。
*
* @return 节点名称和节点 ID 属性
*/
@Override
protected String[] getKeywordSearchProperties() {
return new String[]{"nodeName", "nodeId"};
}
@GetMapping("/getListByRecordId") @GetMapping("/getListByRecordId")
public Result<List<WorkflowExecStep>> getListByRecordId(BigInteger recordId) { public Result<List<WorkflowExecStep>> getListByRecordId(BigInteger recordId) {
if (recordId == null) { if (recordId == null) {

View File

@@ -0,0 +1,117 @@
package tech.easyflow.admin.controller.ai;
import cn.dev33.satoken.annotation.SaIgnore;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import tech.easyflow.admin.service.ai.WorkflowPublicChatService;
import tech.easyflow.ai.share.WorkflowSharePolicy;
import tech.easyflow.common.domain.Result;
import tech.easyflow.common.vo.UploadResVo;
import tech.easyflow.common.web.jsonbody.JsonBody;
import java.util.Map;
/**
* 工作流对话匿名分享接口。
*/
@SaIgnore
@RestController
@RequestMapping("/api/v1/workflowChat/public")
public class WorkflowPublicChatController {
private final WorkflowPublicChatService publicChatService;
public WorkflowPublicChatController(
WorkflowPublicChatService publicChatService
) {
this.publicChatService = publicChatService;
}
@GetMapping("/descriptor")
public Result<Map<String, Object>> descriptor(HttpServletRequest request) {
return Result.ok(publicChatService.descriptor(
shareKey(request),
visitorId(request)
));
}
@PostMapping(value = "/run", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public SseEmitter run(
@JsonBody("variables") Map<String, Object> variables,
HttpServletRequest request
) {
return publicChatService.run(
shareKey(request),
visitorId(request),
variables
);
}
@GetMapping("/execution")
public Result<Map<String, Object>> execution(
@RequestParam String executeId,
HttpServletRequest request
) {
return Result.ok(publicChatService.detail(
shareKey(request),
visitorId(request),
executeId
));
}
@PostMapping("/cancel")
public Result<Boolean> cancel(
@JsonBody(value = "executeId", required = true) String executeId,
HttpServletRequest request
) {
return Result.ok(publicChatService.cancel(
shareKey(request),
visitorId(request),
executeId
));
}
@PostMapping("/resume")
public Result<Void> resume(
@JsonBody(value = "executeId", required = true) String executeId,
@JsonBody("confirmParams") Map<String, Object> confirmParams,
HttpServletRequest request
) {
publicChatService.resume(
shareKey(request),
visitorId(request),
executeId,
confirmParams
);
return Result.ok();
}
@PostMapping(value = "/upload", produces = MediaType.APPLICATION_JSON_VALUE)
public Result<UploadResVo> upload(
@RequestParam("file") MultipartFile file,
@RequestParam("parameterName") String parameterName,
HttpServletRequest request
) {
return Result.ok(publicChatService.upload(
shareKey(request),
visitorId(request),
parameterName,
file
));
}
private String shareKey(HttpServletRequest request) {
return request.getHeader(WorkflowSharePolicy.CHAT_SHARE_KEY_HEADER);
}
private String visitorId(HttpServletRequest request) {
return request.getHeader(WorkflowSharePolicy.CHAT_VISITOR_HEADER);
}
}

View File

@@ -0,0 +1,271 @@
package tech.easyflow.admin.controller.ai;
import cn.dev33.satoken.annotation.SaCheckPermission;
import cn.dev33.satoken.annotation.SaIgnore;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import tech.easyflow.ai.entity.Workflow;
import tech.easyflow.ai.entity.WorkflowShare;
import tech.easyflow.ai.service.KnowledgeShareAuditService;
import tech.easyflow.ai.service.WorkflowService;
import tech.easyflow.ai.service.WorkflowShareService;
import tech.easyflow.ai.share.WorkflowSharePolicy;
import tech.easyflow.ai.vo.WorkflowShareCreateResult;
import tech.easyflow.common.domain.Result;
import tech.easyflow.common.entity.LoginAccount;
import tech.easyflow.common.satoken.util.SaTokenUtil;
import tech.easyflow.common.util.RequestUtil;
import tech.easyflow.common.web.exceptions.BusinessException;
import tech.easyflow.common.web.jsonbody.JsonBody;
import tech.easyflow.system.enums.CategoryResourceType;
import tech.easyflow.system.enums.ResourceAction;
import tech.easyflow.system.service.ResourceAccessService;
import javax.annotation.Resource;
import java.math.BigInteger;
import java.net.URI;
import java.net.URISyntaxException;
import java.util.Map;
/**
* 工作流分享管理接口。
*/
@RestController
@RequestMapping("/api/v1/workflowShare")
public class WorkflowShareController {
@Resource
private WorkflowShareService workflowShareService;
@Resource
private WorkflowService workflowService;
@Resource
private ResourceAccessService resourceAccessService;
@Resource
private KnowledgeShareAuditService knowledgeShareAuditService;
/**
* 创建或刷新已发布工作流的对话分享链接。
*
* @param request HTTP 请求
* @param workflowId 工作流 ID
* @return 分享创建结果
*/
@PostMapping("/url/create")
@SaCheckPermission("/api/v1/workflow/save")
public Result<WorkflowShareCreateResult> createUrlShare(
HttpServletRequest request,
@JsonBody("workflowId") BigInteger workflowId
) {
Workflow workflow = workflowService.getById(workflowId);
if (workflow == null) {
throw new BusinessException("工作流不存在");
}
resourceAccessService.assertAccess(
CategoryResourceType.WORKFLOW,
workflow,
ResourceAction.MANAGE,
"无权限分享工作流"
);
LoginAccount loginAccount = SaTokenUtil.getLoginAccount();
WorkflowShareCreateResult result = workflowShareService.createChatShare(
workflowId,
loginAccount.getTenantId(),
loginAccount.getDeptId(),
loginAccount.getId(),
buildShareBaseUrl(request)
);
knowledgeShareAuditService.log(
loginAccount.getId(),
"创建工作流对话分享",
"WORKFLOW_CHAT_SHARE_CREATE",
request.getRequestURI(),
Map.of("workflowId", workflowId, "shareId", result.getId())
);
return Result.ok(result);
}
/**
* 解析当前对话分享指向的工作流。
*
* @param request HTTP 请求
* @return 工作流标识
*/
@GetMapping("/resolve")
@SaIgnore
public Result<Map<String, BigInteger>> resolveUrlShare(HttpServletRequest request) {
WorkflowShare share = workflowShareService.resolvePublicChatShare(
request.getHeader(WorkflowSharePolicy.CHAT_SHARE_KEY_HEADER)
);
return Result.ok(Map.of("workflowId", share.getWorkflowId()));
}
/**
* 根据管理端来源构建工作流分享基础 URL。
*
* @param request HTTP 请求
* @return 工作流独立分享页 URL
*/
private String buildShareBaseUrl(HttpServletRequest request) {
String refererBaseUrl = extractFrontendBaseUrl(RequestUtil.getReferer(request));
if (refererBaseUrl != null) {
return refererBaseUrl + "/share/workflow";
}
String forwardedOrigin = buildForwardedOrigin(request);
if (forwardedOrigin != null) {
return forwardedOrigin
+ normalizeBasePath(firstHeaderValue(request.getHeader("X-Forwarded-Prefix")))
+ "/share/workflow";
}
String origin = normalizeOrigin(request.getHeader("Origin"));
if (origin != null) {
return origin + normalizeBasePath(request.getContextPath()) + "/share/workflow";
}
StringBuilder builder = new StringBuilder();
builder.append(request.getScheme()).append("://").append(request.getServerName());
if (request.getServerPort() != 80 && request.getServerPort() != 443) {
builder.append(':').append(request.getServerPort());
}
return builder.append(normalizeBasePath(request.getContextPath()))
.append("/share/workflow")
.toString();
}
/**
* 从来源地址提取前端 origin 与部署基路径。
*
* @param sourceUrl 来源地址
* @return origin 与部署基路径,无法解析时返回 {@code null}
*/
private String extractFrontendBaseUrl(String sourceUrl) {
if (sourceUrl == null || sourceUrl.isBlank()) {
return null;
}
try {
URI uri = new URI(sourceUrl.trim());
if (uri.getScheme() == null || uri.getHost() == null) {
return null;
}
String origin = extractOrigin(sourceUrl);
return origin == null ? null : origin + inferFrontendBasePath(uri.getPath());
} catch (URISyntaxException e) {
return null;
}
}
/**
* 从前端页面路径推断部署基路径。
*
* @param path 页面路径
* @return 规范化后的部署基路径
*/
private String inferFrontendBasePath(String path) {
if (path == null || path.isBlank() || "/".equals(path)) {
return "";
}
for (String marker : new String[]{"/ai/", "/auth/", "/share/"}) {
int markerIndex = path.indexOf(marker);
if (markerIndex > 0) {
return normalizeBasePath(path.substring(0, markerIndex));
}
if (markerIndex == 0) {
return "";
}
}
return "";
}
/**
* 规范化部署基路径。
*
* @param basePath 原始基路径
* @return 无尾斜杠的基路径
*/
private String normalizeBasePath(String basePath) {
if (basePath == null || basePath.isBlank() || "/".equals(basePath.trim())) {
return "";
}
String normalized = basePath.trim();
if (!normalized.startsWith("/")) {
normalized = "/" + normalized;
}
while (normalized.endsWith("/") && normalized.length() > 1) {
normalized = normalized.substring(0, normalized.length() - 1);
}
return normalized;
}
/**
* 按反向代理头构建外部访问 origin。
*
* @param request HTTP 请求
* @return 外部 origin缺少代理头时返回 {@code null}
*/
private String buildForwardedOrigin(HttpServletRequest request) {
String proto = firstHeaderValue(request.getHeader("X-Forwarded-Proto"));
String host = firstHeaderValue(request.getHeader("X-Forwarded-Host"));
if (proto == null || host == null) {
return null;
}
return normalizeOrigin(proto + "://" + host);
}
/**
* 从 URL 提取 origin。
*
* @param url 完整 URL
* @return origin无法解析时返回 {@code null}
*/
private String extractOrigin(String url) {
if (url == null || url.isBlank()) {
return null;
}
try {
URI uri = new URI(url.trim());
if (uri.getScheme() == null || uri.getHost() == null) {
return null;
}
StringBuilder builder = new StringBuilder();
builder.append(uri.getScheme()).append("://").append(uri.getHost());
if (uri.getPort() != -1 && uri.getPort() != 80 && uri.getPort() != 443) {
builder.append(':').append(uri.getPort());
}
return builder.toString();
} catch (URISyntaxException e) {
return null;
}
}
/**
* 规范化 origin。
*
* @param origin 原始 origin
* @return 规范化结果
*/
private String normalizeOrigin(String origin) {
return extractOrigin(origin);
}
/**
* 读取代理头的首个有效值。
*
* @param value 原始请求头
* @return 首个有效值
*/
private String firstHeaderValue(String value) {
if (value == null || value.isBlank()) {
return null;
}
int commaIndex = value.indexOf(',');
String normalized = commaIndex >= 0 ? value.substring(0, commaIndex) : value;
normalized = normalized.trim();
return normalized.isEmpty() ? null : normalized;
}
}

View File

@@ -1,11 +1,14 @@
package tech.easyflow.admin.controller.ai.support; package tech.easyflow.admin.controller.ai.support;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import tech.easyflow.ai.entity.Bot; import com.mybatisflex.core.query.QueryWrapper;
import tech.easyflow.agent.entity.Agent;
import tech.easyflow.ai.entity.DocumentCollection; import tech.easyflow.ai.entity.DocumentCollection;
import tech.easyflow.ai.entity.Plugin; import tech.easyflow.ai.entity.Plugin;
import tech.easyflow.ai.entity.Workflow; import tech.easyflow.ai.entity.Workflow;
import tech.easyflow.skill.entity.Skill;
import tech.easyflow.system.service.SysAccountService; import tech.easyflow.system.service.SysAccountService;
import tech.easyflow.system.entity.SysAccount;
import javax.annotation.Resource; import javax.annotation.Resource;
import java.math.BigInteger; import java.math.BigInteger;
@@ -39,15 +42,6 @@ public class AiResourceCreatorNameSupport {
fillCreatorNames(workflows, Workflow::getCreatedBy, Workflow::setCreatedByName); fillCreatorNames(workflows, Workflow::getCreatedBy, Workflow::setCreatedByName);
} }
/**
* 批量填充聊天助手创建人名称。
*
* @param bots 聊天助手集合
*/
public void fillBotCreatorNames(Collection<Bot> bots) {
fillCreatorNames(bots, Bot::getCreatedBy, Bot::setCreatedByName);
}
/** /**
* 批量填充知识库创建人名称。 * 批量填充知识库创建人名称。
* *
@@ -66,6 +60,55 @@ public class AiResourceCreatorNameSupport {
fillCreatorNames(plugins, Plugin::getCreatedBy, Plugin::setCreatedByName); fillCreatorNames(plugins, Plugin::getCreatedBy, Plugin::setCreatedByName);
} }
/**
* 批量填充 Agent 创建人名称。
*
* @param agents Agent 集合
*/
public void fillAgentCreatorNames(Collection<Agent> agents) {
fillCreatorNames(agents, Agent::getCreatedBy, Agent::setCreatedByName);
}
/**
* 批量填充 Skill 创建人名称。
*
* @param skills Skill 集合
*/
public void fillSkillCreatorNames(Collection<Skill> skills) {
if (skills == null || skills.isEmpty()) {
return;
}
LinkedHashSet<BigInteger> creatorIds = skills.stream().map(Skill::getCreatedBy)
.filter(Objects::nonNull)
.collect(java.util.stream.Collectors.toCollection(LinkedHashSet::new));
if (creatorIds.isEmpty()) {
return;
}
Map<BigInteger, String> labels = sysAccountService.list(QueryWrapper.create()
.select(SysAccount::getId, SysAccount::getNickname, SysAccount::getLoginName)
.in(SysAccount::getId, creatorIds)).stream()
.collect(java.util.stream.Collectors.toMap(SysAccount::getId, this::skillCreatorLabel,
(left, right) -> left, java.util.LinkedHashMap::new));
skills.forEach(skill -> skill.setCreatedByName(
labels.getOrDefault(skill.getCreatedBy(), String.valueOf(skill.getCreatedBy()))));
}
/**
* 将 Skill 创建人格式化为“昵称(账号)”。
*
* @param account 创建人账号
* @return 创建人展示标签
*/
private String skillCreatorLabel(SysAccount account) {
String nickname = account.getNickname() == null ? "" : account.getNickname().trim();
String loginName = account.getLoginName() == null ? "" : account.getLoginName().trim();
if (nickname.isBlank()) {
return loginName.isBlank() ? String.valueOf(account.getId()) : loginName;
}
return loginName.isBlank() || nickname.equals(loginName)
? nickname : nickname + "" + loginName + "";
}
/** /**
* 通用的创建人名称填充逻辑。 * 通用的创建人名称填充逻辑。
* *
@@ -74,7 +117,7 @@ public class AiResourceCreatorNameSupport {
* @param createdByNameSetter 创建人名称回填函数 * @param createdByNameSetter 创建人名称回填函数
* @param <T> 资源类型 * @param <T> 资源类型
*/ */
private <T> void fillCreatorNames( <T> void fillCreatorNames(
Collection<T> resources, Collection<T> resources,
Function<T, Number> createdByGetter, Function<T, Number> createdByGetter,
BiConsumer<T, String> createdByNameSetter BiConsumer<T, String> createdByNameSetter

View File

@@ -0,0 +1,33 @@
package tech.easyflow.admin.controller.ai.support;
import org.springframework.stereotype.Component;
import tech.easyflow.ai.entity.Bot;
import java.util.Collection;
/**
* 为旧 Bot 资源批量补充创建人展示名称。
*/
@Component
public class BotResourceCreatorNameSupport {
private final AiResourceCreatorNameSupport creatorNameSupport;
/**
* 创建 Bot 创建人名称填充组件。
*
* @param creatorNameSupport 通用 AI 资源创建人名称组件
*/
public BotResourceCreatorNameSupport(AiResourceCreatorNameSupport creatorNameSupport) {
this.creatorNameSupport = creatorNameSupport;
}
/**
* 批量填充 Bot 创建人名称。
*
* @param bots Bot 集合
*/
public void fillCreatorNames(Collection<Bot> bots) {
creatorNameSupport.fillCreatorNames(bots, Bot::getCreatedBy, Bot::setCreatedByName);
}
}

View File

@@ -2,15 +2,22 @@ package tech.easyflow.admin.controller.auth;
import cn.dev33.satoken.annotation.SaIgnore; import cn.dev33.satoken.annotation.SaIgnore;
import cn.dev33.satoken.stp.StpUtil; import cn.dev33.satoken.stp.StpUtil;
import com.alibaba.fastjson2.JSONObject;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
import tech.easyflow.auth.entity.LoginDTO; import tech.easyflow.auth.entity.*;
import tech.easyflow.auth.entity.LoginVO; import tech.easyflow.auth.service.AuthCredentialKeyService;
import tech.easyflow.auth.service.AuthService; import tech.easyflow.auth.service.AuthService;
import tech.easyflow.common.captcha.tainai.CaptchaVerificationService;
import tech.easyflow.common.constant.Constants;
import tech.easyflow.common.domain.Result; import tech.easyflow.common.domain.Result;
import tech.easyflow.common.entity.LoginAccount;
import tech.easyflow.common.satoken.util.SaTokenUtil;
import tech.easyflow.common.web.exceptions.BusinessException;
import tech.easyflow.common.web.jsonbody.JsonBody; import tech.easyflow.common.web.jsonbody.JsonBody;
import tech.easyflow.log.annotation.LogRecord;
import javax.annotation.Resource; import javax.annotation.Resource;
import java.util.List; import java.util.List;
@@ -21,9 +28,23 @@ public class AuthController {
@Resource @Resource
private AuthService authService; private AuthService authService;
@Resource
private AuthCredentialKeyService credentialKeyService;
@Resource
private CaptchaVerificationService captchaVerificationService;
@GetMapping("credential-key")
public Result<CredentialKeyVO> credentialKey() {
return Result.ok(credentialKeyService.getCurrentCredentialKey());
}
@PostMapping("login") @PostMapping("login")
public Result<LoginVO> login(@JsonBody LoginDTO loginDTO) { public Result<LoginVO> login(@JsonBody EncryptedCredentialDTO encryptedCredential) {
JSONObject payload = credentialKeyService.decryptPayload(encryptedCredential);
if (!captchaVerificationService.verify(payload.getString("validToken"))) {
return captchaVerificationService.failureResult();
}
LoginDTO loginDTO = toLoginDTO(payload);
LoginVO res = authService.login(loginDTO); LoginVO res = authService.login(loginDTO);
return Result.ok(res); return Result.ok(res);
} }
@@ -45,4 +66,23 @@ public class AuthController {
List<String> permissionList = StpUtil.getPermissionList(); List<String> permissionList = StpUtil.getPermissionList();
return Result.ok(permissionList); return Result.ok(permissionList);
} }
@PostMapping("credential-key/rotate")
@LogRecord("轮换认证传输密钥")
public Result<Void> rotateCredentialKey(@JsonBody CredentialKeyRotateDTO rotateDTO) {
StpUtil.checkLogin();
LoginAccount loginAccount = SaTokenUtil.getLoginAccount();
if (loginAccount == null || !Constants.SUPER_ADMIN_ID.equals(loginAccount.getId())) {
throw new BusinessException("仅超级管理员可轮换认证密钥");
}
credentialKeyService.rotate(rotateDTO != null && Boolean.TRUE.equals(rotateDTO.getEmergency()));
return Result.ok();
}
private LoginDTO toLoginDTO(JSONObject payload) {
LoginDTO loginDTO = new LoginDTO();
loginDTO.setAccount(payload.getString("account"));
loginDTO.setPassword(payload.getString("password"));
return loginDTO;
}
} }

View File

@@ -6,6 +6,7 @@ import tech.easyflow.common.dict.Dict;
import tech.easyflow.common.dict.DictItem; import tech.easyflow.common.dict.DictItem;
import tech.easyflow.common.dict.DictLoader; import tech.easyflow.common.dict.DictLoader;
import tech.easyflow.common.dict.DictManager; import tech.easyflow.common.dict.DictManager;
import tech.easyflow.common.web.exceptions.BusinessException;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
@@ -16,18 +17,30 @@ import java.util.Collections;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
/**
* 提供已注册静态字典的查询接口。
*/
@RestController @RestController
@RequestMapping("/api/v1/dict/") @RequestMapping("/api/v1/dict/")
public class DictController { public class DictController {
@Resource @Resource
DictManager dictManager; private DictManager dictManager;
/**
* 查询指定静态字典的选项。
*
* @param code 字典编码
* @param keyword 搜索关键字
* @param request HTTP 请求
* @return 字典选项
* @throws BusinessException 字典未注册时抛出
*/
@GetMapping("/items/{code}") @GetMapping("/items/{code}")
public Result<List<DictItem>> items(@PathVariable("code") String code, String keyword, HttpServletRequest request) { public Result<List<DictItem>> items(@PathVariable("code") String code, String keyword, HttpServletRequest request) {
DictLoader loader = dictManager.getLoader(code); DictLoader loader = dictManager.getLoader(code);
if (loader == null) { if (loader == null) {
return Result.ok(Collections.emptyList()); throw new BusinessException("字典不存在或不支持公共查询");
} }
Map<String, String[]> parameterMap = request.getParameterMap(); Map<String, String[]> parameterMap = request.getParameterMap();
Dict dict = loader.load(keyword, parameterMap); Dict dict = loader.load(keyword, parameterMap);

View File

@@ -10,6 +10,8 @@ import tech.easyflow.admin.model.dashboard.DashboardOverviewVo;
import tech.easyflow.admin.model.dashboard.DashboardUserRankItemVo; import tech.easyflow.admin.model.dashboard.DashboardUserRankItemVo;
import tech.easyflow.admin.model.dashboard.DashboardUserRankQuery; import tech.easyflow.admin.model.dashboard.DashboardUserRankQuery;
import tech.easyflow.admin.service.dashboard.DashboardService; import tech.easyflow.admin.service.dashboard.DashboardService;
import tech.easyflow.agent.service.AgentOptionQueryService;
import tech.easyflow.agent.vo.AgentOptionView;
import tech.easyflow.common.domain.Result; import tech.easyflow.common.domain.Result;
import tech.easyflow.common.satoken.util.SaTokenUtil; import tech.easyflow.common.satoken.util.SaTokenUtil;
@@ -28,9 +30,29 @@ import java.util.List;
public class DashboardController { public class DashboardController {
private final DashboardService dashboardService; private final DashboardService dashboardService;
private final AgentOptionQueryService agentOptionQueryService;
public DashboardController(DashboardService dashboardService) { /**
* 创建管理端工作台控制器。
*
* @param dashboardService 工作台统计服务
* @param agentOptionQueryService Agent 安全选项服务
*/
public DashboardController(DashboardService dashboardService,
AgentOptionQueryService agentOptionQueryService) {
this.dashboardService = dashboardService; this.dashboardService = dashboardService;
this.agentOptionQueryService = agentOptionQueryService;
}
/**
* 查询工作台筛选可使用的 Agent。
*
* @return Agent 安全选项
*/
@GetMapping("/agentOptions")
@SaCheckPermission("/api/v1/dashboard/query")
public Result<List<AgentOptionView>> agentOptions() {
return Result.ok(agentOptionQueryService.listAgentOptions(false));
} }
@GetMapping("/overview") @GetMapping("/overview")

View File

@@ -24,6 +24,11 @@ import java.util.List;
@RequestMapping("/api/v1/datacenterDataset") @RequestMapping("/api/v1/datacenterDataset")
public class DatacenterDatasetController { public class DatacenterDatasetController {
/** 对外 Schema 接口的默认字段页码。 */
private static final long DEFAULT_FIELD_PAGE_NUMBER = 1L;
/** 对外 Schema 接口的默认字段页大小。 */
private static final long DEFAULT_FIELD_PAGE_SIZE = 200L;
@Resource @Resource
private DatacenterDatasetQueryService queryService; private DatacenterDatasetQueryService queryService;
@Resource @Resource
@@ -32,13 +37,18 @@ public class DatacenterDatasetController {
@PostMapping("/queryPage") @PostMapping("/queryPage")
@SaCheckPermission("/api/v1/datacenterSource/query") @SaCheckPermission("/api/v1/datacenterSource/query")
public Result<Page<Row>> queryPage(@RequestBody DatacenterQueryRequest request) { public Result<Page<Row>> queryPage(@RequestBody DatacenterQueryRequest request) {
return Result.ok(queryService.queryPage(request)); return Result.ok(queryService.queryPage(
request, SaTokenUtil.getLoginAccount()));
} }
@GetMapping("/schema") @GetMapping("/schema")
@SaCheckPermission("/api/v1/datacenterSource/query") @SaCheckPermission("/api/v1/datacenterSource/query")
public Result<DatacenterSchemaResponse> schema(DatasetRef datasetRef) { public Result<DatacenterSchemaResponse> schema(
return Result.ok(queryService.getSchema(datasetRef)); DatasetRef datasetRef,
@RequestParam(defaultValue = "1") Long fieldPageNumber,
@RequestParam(defaultValue = "200") Long fieldPageSize) {
return Result.ok(queryService.getSchema(
datasetRef, fieldPageNumber, fieldPageSize));
} }
@GetMapping("/managedTables") @GetMapping("/managedTables")
@@ -63,6 +73,13 @@ public class DatacenterDatasetController {
request == null ? List.of() : request.getFields(), request == null ? List.of() : request.getFields(),
account account
); );
return Result.ok(queryService.getSchema(registryService.resolveDatasetRef(table.getId()))); return Result.ok(queryService.getSchema(
registryService.resolveDatasetRef(table.getId()),
request == null || request.getFieldPageNumber() == null
? DEFAULT_FIELD_PAGE_NUMBER
: request.getFieldPageNumber(),
request == null || request.getFieldPageSize() == null
? DEFAULT_FIELD_PAGE_SIZE
: request.getFieldPageSize()));
} }
} }

View File

@@ -6,6 +6,7 @@ import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile; import org.springframework.web.multipart.MultipartFile;
import tech.easyflow.common.domain.Result; import tech.easyflow.common.domain.Result;
@@ -32,11 +33,21 @@ public class DatacenterExcelController {
@Resource @Resource
private DatacenterExcelImportService excelImportService; private DatacenterExcelImportService excelImportService;
/**
* 上传并导入 Excel 工作簿。
*
* @param file Excel 工作簿
* @param sourceName 数据源名称,留空时使用文件名
* @return 导入任务及新建数据源标识
* @throws Exception 文件解析或数据写入失败时抛出
*/
@PostMapping("/import") @PostMapping("/import")
@SaCheckPermission("/api/v1/datacenterSource/save") @SaCheckPermission("/api/v1/datacenterSource/save")
public Result<DatacenterImportJob> importWorkbook(MultipartFile file) throws Exception { public Result<DatacenterImportJob> importWorkbook(
@RequestParam("file") MultipartFile file,
@RequestParam(value = "sourceName", required = false) String sourceName) throws Exception {
LoginAccount account = SaTokenUtil.getLoginAccount(); LoginAccount account = SaTokenUtil.getLoginAccount();
return Result.ok(excelImportService.importWorkbook(file, account)); return Result.ok(excelImportService.importWorkbook(file, sourceName, account));
} }
@PostMapping("/split") @PostMapping("/split")

View File

@@ -0,0 +1,84 @@
package tech.easyflow.admin.controller.datacenter;
import cn.dev33.satoken.annotation.SaCheckPermission;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import tech.easyflow.common.domain.Result;
import tech.easyflow.common.entity.LoginAccount;
import tech.easyflow.common.satoken.util.SaTokenUtil;
import tech.easyflow.datacenter.execution.model.DatacenterSqlConsoleRequest;
import tech.easyflow.datacenter.execution.model.DatacenterSqlConsoleResult;
import tech.easyflow.datacenter.execution.model.DatacenterSqlCancelRequest;
import tech.easyflow.datacenter.federation.DatacenterFederationQueryService;
import tech.easyflow.datacenter.federation.DatacenterFederationQueryCancellationService;
import tech.easyflow.datacenter.meta.entity.DatacenterSource;
import tech.easyflow.datacenter.meta.service.DatacenterDatasetRegistryService;
/**
* 数据中枢管理端只读 SQL 控制台。
*/
@RestController
@RequestMapping("/api/v1/datacenterQuery")
public class DatacenterQueryController {
private final DatacenterDatasetRegistryService registryService;
private final DatacenterFederationQueryService queryService;
private final DatacenterFederationQueryCancellationService cancellationService;
/**
* 创建查询 Controller。
*
* @param registryService 数据集注册服务
* @param queryService Federation 查询服务
* @param cancellationService 跨节点查询取消服务
*/
public DatacenterQueryController(
DatacenterDatasetRegistryService registryService,
DatacenterFederationQueryService queryService,
DatacenterFederationQueryCancellationService cancellationService) {
this.registryService = registryService;
this.queryService = queryService;
this.cancellationService = cancellationService;
}
/**
* 执行一条受 Calcite 与业务 Policy 校验的只读 SQL。
*
* @param request 查询请求
* @return 有界查询结果
*/
@PostMapping("/execute")
@SaCheckPermission("/api/v1/datacenterSource/query")
public Result<DatacenterSqlConsoleResult> execute(
@RequestBody DatacenterSqlConsoleRequest request) {
LoginAccount account = SaTokenUtil.getLoginAccount();
DatacenterSource source = registryService.getSourceRequired(
request == null ? null : request.sourceId());
return Result.ok(queryService.execute(
source,
request == null ? null : request.sql(),
java.util.List.of(),
request == null ? null : request.maxRows(),
account,
"MANUAL",
account == null || account.getId() == null
? null : account.getId().toString(),
request == null ? null : request.queryId()));
}
/**
* 取消当前租户在任一节点执行的 SQL 查询。
*
* @param request 取消请求
* @return 是否在本地或集群中接受取消提示
*/
@PostMapping("/cancel")
@SaCheckPermission("/api/v1/datacenterSource/query")
public Result<Boolean> cancel(@RequestBody DatacenterSqlCancelRequest request) {
LoginAccount account = SaTokenUtil.getLoginAccount();
return Result.ok(cancellationService.cancel(
request == null ? null : request.queryId(), account));
}
}

View File

@@ -8,10 +8,16 @@ import tech.easyflow.common.entity.LoginAccount;
import tech.easyflow.common.satoken.util.SaTokenUtil; import tech.easyflow.common.satoken.util.SaTokenUtil;
import tech.easyflow.datacenter.entity.DatacenterTable; import tech.easyflow.datacenter.entity.DatacenterTable;
import tech.easyflow.datacenter.execution.model.DatacenterConnectionTestResult; import tech.easyflow.datacenter.execution.model.DatacenterConnectionTestResult;
import tech.easyflow.datacenter.meta.entity.DatacenterSource;
import tech.easyflow.datacenter.meta.model.DatacenterBatchRegisterRequest; import tech.easyflow.datacenter.meta.model.DatacenterBatchRegisterRequest;
import tech.easyflow.datacenter.meta.model.DatacenterCatalogMeta; import tech.easyflow.datacenter.meta.model.DatacenterCatalogMeta;
import tech.easyflow.datacenter.meta.model.DatacenterMetadataPage;
import tech.easyflow.datacenter.meta.model.DatacenterRemoveSourceRequest; import tech.easyflow.datacenter.meta.model.DatacenterRemoveSourceRequest;
import tech.easyflow.datacenter.meta.model.DatacenterSourceActivateRequest;
import tech.easyflow.datacenter.meta.model.DatacenterSourceCandidateMetadataRequest;
import tech.easyflow.datacenter.meta.model.DatacenterSourceCandidateCatalogRequest;
import tech.easyflow.datacenter.meta.model.DatacenterSourceDraftRequest;
import tech.easyflow.datacenter.meta.model.DatacenterSourceReconfigureRequest;
import tech.easyflow.datacenter.meta.model.DatacenterSourceView;
import tech.easyflow.datacenter.meta.model.DatacenterTableDetailMeta; import tech.easyflow.datacenter.meta.model.DatacenterTableDetailMeta;
import tech.easyflow.datacenter.meta.service.DatacenterSourceService; import tech.easyflow.datacenter.meta.service.DatacenterSourceService;
@@ -19,6 +25,9 @@ import javax.annotation.Resource;
import java.math.BigInteger; import java.math.BigInteger;
import java.util.List; import java.util.List;
/**
* 数据源绑定、生命周期与元数据浏览接口。
*/
@RestController @RestController
@RequestMapping("/api/v1/datacenterSource") @RequestMapping("/api/v1/datacenterSource")
public class DatacenterSourceController { public class DatacenterSourceController {
@@ -26,23 +35,100 @@ public class DatacenterSourceController {
@Resource @Resource
private DatacenterSourceService sourceService; private DatacenterSourceService sourceService;
@PostMapping("/testConnection") @PostMapping("/draft")
@SaCheckPermission("/api/v1/datacenterSource/query") @SaCheckPermission("/api/v1/datacenterSource/save")
public Result<DatacenterConnectionTestResult> testConnection(@RequestBody DatacenterSource source) { public Result<DatacenterSourceView> saveDraft(@RequestBody DatacenterSourceDraftRequest request) {
LoginAccount account = SaTokenUtil.getLoginAccount(); LoginAccount account = SaTokenUtil.getLoginAccount();
return Result.ok(sourceService.testConnection(source, account)); return Result.ok(sourceService.saveDraft(request, account));
} }
@PostMapping("/save") @PostMapping("/{sourceId}/probe")
@SaCheckPermission("/api/v1/datacenterSource/save") @SaCheckPermission("/api/v1/datacenterSource/query")
public Result<DatacenterSource> save(@RequestBody DatacenterSource source) { public Result<DatacenterConnectionTestResult> probe(@PathVariable BigInteger sourceId) {
LoginAccount account = SaTokenUtil.getLoginAccount(); LoginAccount account = SaTokenUtil.getLoginAccount();
return Result.ok(sourceService.saveSource(source, account)); return Result.ok(sourceService.probe(sourceId, account));
}
@PostMapping("/activate")
@SaCheckPermission("/api/v1/datacenterSource/save")
public Result<DatacenterSourceView> activate(@RequestBody DatacenterSourceActivateRequest request) {
LoginAccount account = SaTokenUtil.getLoginAccount();
return Result.ok(sourceService.activate(request, account));
}
/**
* 探测活动数据源的未发布候选配置。
*
* @param request 候选连接配置
* @return 连接探测结果
*/
@PostMapping("/candidate/probe")
@SaCheckPermission("/api/v1/datacenterSource/save")
public Result<DatacenterConnectionTestResult> probeCandidate(
@RequestBody DatacenterSourceDraftRequest request) {
return Result.ok(sourceService.probeCandidate(
request, SaTokenUtil.getLoginAccount()));
}
/**
* 浏览活动数据源候选配置可访问的命名空间。
*
* @param request 候选连接配置
* @return 命名空间列表
*/
@PostMapping("/candidate/catalogs")
@SaCheckPermission("/api/v1/datacenterSource/save")
public Result<List<DatacenterCatalogMeta>> candidateCatalogs(
@RequestBody DatacenterSourceDraftRequest request) {
return Result.ok(sourceService.listCandidateCatalogs(
request, SaTokenUtil.getLoginAccount()));
}
/**
* 分页浏览候选配置可访问的命名空间。
*
* @param request 候选配置和分页条件
* @return 有界命名空间列表
*/
@PostMapping("/candidate/catalogs/page")
@SaCheckPermission("/api/v1/datacenterSource/save")
public Result<DatacenterMetadataPage<DatacenterCatalogMeta>> candidateCatalogsPage(
@RequestBody DatacenterSourceCandidateCatalogRequest request) {
return Result.ok(sourceService.listCandidateCatalogsPage(
request, SaTokenUtil.getLoginAccount()));
}
/**
* 分页浏览活动数据源候选配置可访问的表。
*
* @param request 候选配置与分页条件
* @return 有界表列表
*/
@PostMapping("/candidate/tables")
@SaCheckPermission("/api/v1/datacenterSource/save")
public Result<DatacenterMetadataPage<DatacenterTable>> candidateTables(
@RequestBody DatacenterSourceCandidateMetadataRequest request) {
return Result.ok(sourceService.listCandidateTables(
request, SaTokenUtil.getLoginAccount()));
}
/**
* 原地发布活动数据源的新连接配置和纳管范围。
*
* @param request 重配置请求
* @return 发布后的数据源视图
*/
@PostMapping("/reconfigure")
@SaCheckPermission("/api/v1/datacenterSource/save")
public Result<DatacenterSourceView> reconfigure(
@RequestBody DatacenterSourceReconfigureRequest request) {
return Result.ok(sourceService.reconfigure(
request, SaTokenUtil.getLoginAccount()));
} }
@GetMapping("/page") @GetMapping("/page")
@SaCheckPermission("/api/v1/datacenterSource/query") @SaCheckPermission("/api/v1/datacenterSource/query")
public Result<Page<DatacenterSource>> page(Long pageNumber, Long pageSize) { public Result<Page<DatacenterSourceView>> page(Long pageNumber, Long pageSize) {
LoginAccount account = SaTokenUtil.getLoginAccount(); LoginAccount account = SaTokenUtil.getLoginAccount();
return Result.ok(sourceService.pageSources(pageNumber, pageSize, account)); return Result.ok(sourceService.pageSources(pageNumber, pageSize, account));
} }
@@ -54,19 +140,53 @@ public class DatacenterSourceController {
return Result.ok(sourceService.listCatalogs(sourceId, account)); return Result.ok(sourceService.listCatalogs(sourceId, account));
} }
/**
* 分页浏览当前数据源的命名空间。
*
* @param sourceId 数据源 ID
* @param keyword 名称搜索词
* @param pageNumber 页码
* @param pageSize 每页大小
* @return 有界命名空间列表
*/
@GetMapping("/catalogs/page")
@SaCheckPermission("/api/v1/datacenterSource/query")
public Result<DatacenterMetadataPage<DatacenterCatalogMeta>> catalogsPage(
BigInteger sourceId,
String keyword,
Long pageNumber,
Long pageSize) {
return Result.ok(sourceService.listCatalogsPage(
sourceId, keyword, pageNumber, pageSize,
SaTokenUtil.getLoginAccount()));
}
@GetMapping("/tables") @GetMapping("/tables")
@SaCheckPermission("/api/v1/datacenterSource/query") @SaCheckPermission("/api/v1/datacenterSource/query")
public Result<List<DatacenterTable>> tables(BigInteger sourceId, String catalogName) { public Result<DatacenterMetadataPage<DatacenterTable>> tables(
BigInteger sourceId,
String catalogName,
String keyword,
Long pageNumber,
Long pageSize) {
LoginAccount account = SaTokenUtil.getLoginAccount(); LoginAccount account = SaTokenUtil.getLoginAccount();
return Result.ok(sourceService.listTables(sourceId, catalogName, account)); return Result.ok(sourceService.listTables(
sourceId, catalogName, keyword, pageNumber, pageSize, account));
} }
@GetMapping("/tableDetail") @GetMapping("/tableDetail")
@SaCheckPermission("/api/v1/datacenterSource/query") @SaCheckPermission("/api/v1/datacenterSource/query")
public Result<DatacenterTableDetailMeta> tableDetail(BigInteger sourceId, String catalogName, String tableName, public Result<DatacenterTableDetailMeta> tableDetail(
@RequestParam(defaultValue = "false") boolean register) { BigInteger sourceId,
String catalogName,
String tableName,
@RequestParam(defaultValue = "false") boolean register,
Long fieldPageNumber,
Long fieldPageSize) {
LoginAccount account = SaTokenUtil.getLoginAccount(); LoginAccount account = SaTokenUtil.getLoginAccount();
return Result.ok(sourceService.getTableDetail(sourceId, catalogName, tableName, register, account)); return Result.ok(sourceService.getTableDetail(
sourceId, catalogName, tableName, register,
fieldPageNumber, fieldPageSize, account));
} }
@PostMapping("/registerBatch") @PostMapping("/registerBatch")
@@ -83,4 +203,44 @@ public class DatacenterSourceController {
sourceService.removeSource(request == null ? null : request.getSourceId(), account); sourceService.removeSource(request == null ? null : request.getSourceId(), account);
return Result.ok(); return Result.ok();
} }
/**
* 停用活动数据源。
*
* @param sourceId 数据源 ID
* @return 停用后的数据源视图
*/
@PostMapping("/{sourceId}/disable")
@SaCheckPermission("/api/v1/datacenterSource/save")
public Result<DatacenterSourceView> disable(@PathVariable BigInteger sourceId) {
return Result.ok(sourceService.disable(
sourceId, SaTokenUtil.getLoginAccount()));
}
/**
* 重新启用已停用数据源。
*
* @param sourceId 数据源 ID
* @return 启用后的数据源视图
*/
@PostMapping("/{sourceId}/enable")
@SaCheckPermission("/api/v1/datacenterSource/save")
public Result<DatacenterSourceView> enable(@PathVariable BigInteger sourceId) {
return Result.ok(sourceService.enable(
sourceId, SaTokenUtil.getLoginAccount()));
}
/**
* 刷新已纳管对象的 JDBC 元数据观测状态。
*
* @param sourceId 数据源 ID
* @return 刷新后的数据源视图
*/
@PostMapping("/{sourceId}/metadata/refresh")
@SaCheckPermission("/api/v1/datacenterSource/save")
public Result<DatacenterSourceView> refreshMetadata(
@PathVariable BigInteger sourceId) {
return Result.ok(sourceService.refreshMetadata(
sourceId, SaTokenUtil.getLoginAccount()));
}
} }

View File

@@ -0,0 +1,161 @@
package tech.easyflow.admin.controller.dataspace;
import cn.dev33.satoken.annotation.SaCheckPermission;
import java.math.BigInteger;
import java.util.List;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import tech.easyflow.common.domain.Result;
import tech.easyflow.common.web.jsonbody.JsonBody;
import tech.easyflow.common.web.exceptions.BusinessException;
import tech.easyflow.dataspace.model.ConnectionDefinition;
import tech.easyflow.dataspace.model.ConnectionView;
import tech.easyflow.dataspace.model.ObjectView;
import tech.easyflow.dataspace.provider.DataspaceProbe;
import tech.easyflow.dataspace.service.DataspaceConnectionService;
/**
* 数据空间物理连接管理端 API。
*/
@RestController
@RequestMapping("/api/v1/dataspaceConnection")
public class DataspaceConnectionController {
private final DataspaceConnectionService connectionService;
/**
* 创建连接控制器。
*
* @param connectionService 连接服务
*/
public DataspaceConnectionController(DataspaceConnectionService connectionService) {
this.connectionService = connectionService;
}
/**
* 查询当前租户连接列表。
*
* @param keyword 搜索关键词
* @return 连接列表
*/
@GetMapping("/list")
@SaCheckPermission("/api/v1/dataspaceConnection/query")
public Result<List<ConnectionView>> list(String keyword) {
return Result.ok(connectionService.list(keyword));
}
/**
* 获取连接详情。
*
* @param id 连接 ID
* @return 连接详情
*/
@GetMapping("/detail")
@SaCheckPermission("/api/v1/dataspaceConnection/query")
public Result<ConnectionView> detail(BigInteger id) {
return Result.ok(connectionService.detail(id));
}
/**
* 测试候选或已保存连接。
*
* @param definition 连接定义
* @return 测试结果
*/
@PostMapping("/test")
@SaCheckPermission("/api/v1/dataspaceConnection/test")
public Result<DataspaceProbe> test(
@JsonBody(required = true, skipConvertError = false) ConnectionDefinition definition) {
return Result.ok(connectionService.test(definition));
}
/**
* 创建或更新连接。
*
* @param definition 连接定义
* @return 保存后的连接
*/
@PostMapping("/save")
@SaCheckPermission("/api/v1/dataspaceConnection/save")
public Result<ConnectionView> save(
@JsonBody(required = true, skipConvertError = false) ConnectionDefinition definition) {
return Result.ok(connectionService.save(definition));
}
/**
* 启用或禁用连接。
*
* @param request 状态变更请求
* @return 变更后的连接
*/
@PostMapping("/status")
@SaCheckPermission("/api/v1/dataspaceConnection/save")
public Result<ConnectionView> status(
@JsonBody(required = true, skipConvertError = false) StatusRequest request) {
if (request == null || request.enabled() == null) {
throw new BusinessException("连接状态不能为空");
}
return Result.ok(connectionService.setEnabled(request.id(), request.enabled()));
}
/**
* 查询当前连接的对象树数据。
*
* @param connectionId 连接 ID
* @param keyword Schema 或表名关键词
* @return 对象列表
*/
@GetMapping("/objects")
@SaCheckPermission("/api/v1/dataspaceConnection/query")
public Result<List<ObjectView>> objects(BigInteger connectionId, String keyword) {
return Result.ok(connectionService.objects(connectionId, keyword));
}
/**
* 刷新连接元数据。
*
* @param request 刷新请求
* @return 新 revision 对象列表
*/
@PostMapping("/refreshMetadata")
@SaCheckPermission("/api/v1/dataspaceConnection/metadata")
public Result<List<ObjectView>> refreshMetadata(
@JsonBody(required = true, skipConvertError = false) RefreshRequest request) {
return Result.ok(connectionService.refreshMetadata(
request.connectionId(), request.expectedRevision()));
}
/**
* 删除未被引用的连接。
*
* @param id 连接 ID
* @return 成功结果
*/
@PostMapping("/remove")
@SaCheckPermission("/api/v1/dataspaceConnection/remove")
public Result<Void> remove(
@JsonBody(value = "id", required = true, skipConvertError = false) BigInteger id) {
connectionService.remove(id);
return Result.ok();
}
/**
* 元数据刷新请求。
*
* @param connectionId 连接 ID
* @param expectedRevision 期望 revision
*/
public record RefreshRequest(BigInteger connectionId, long expectedRevision) {
}
/**
* 连接状态变更请求。
*
* @param id 连接 ID
* @param enabled 是否启用
*/
public record StatusRequest(BigInteger id, Boolean enabled) {
}
}

View File

@@ -0,0 +1,113 @@
package tech.easyflow.admin.controller.dataspace;
import cn.dev33.satoken.annotation.SaCheckPermission;
import java.math.BigInteger;
import java.util.List;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;
import tech.easyflow.common.domain.Result;
import tech.easyflow.common.web.jsonbody.JsonBody;
import tech.easyflow.common.web.exceptions.BusinessException;
import tech.easyflow.dataspace.model.DataspaceDefinition;
import tech.easyflow.dataspace.model.DataspaceSummary;
import tech.easyflow.dataspace.model.DataspaceView;
import tech.easyflow.dataspace.service.DataspaceService;
/**
* 虚拟数据空间管理端 API。
*/
@RestController
@RequestMapping("/api/v1/dataspace")
public class DataspaceController {
private final DataspaceService dataspaceService;
/**
* 创建数据空间控制器。
*
* @param dataspaceService 数据空间服务
*/
public DataspaceController(DataspaceService dataspaceService) {
this.dataspaceService = dataspaceService;
}
/**
* 查询数据空间列表。
*
* @param keyword 搜索关键词
* @return 数据空间摘要
*/
@GetMapping("/list")
@SaCheckPermission("/api/v1/dataspace/query")
public Result<List<DataspaceSummary>> list(String keyword) {
return Result.ok(dataspaceService.list(keyword));
}
/**
* 获取数据空间当前 revision 详情。
*
* @param id 数据空间 ID
* @return 数据空间详情
*/
@GetMapping("/detail")
@SaCheckPermission("/api/v1/dataspace/detail")
public Result<DataspaceView> detail(BigInteger id) {
return Result.ok(dataspaceService.detail(id));
}
/**
* 保存数据空间并生成新 revision。
*
* @param definition 数据空间定义
* @return 保存后的详情
*/
@PostMapping("/save")
@SaCheckPermission("/api/v1/dataspace/save")
public Result<DataspaceView> save(
@RequestBody DataspaceDefinition definition) {
return Result.ok(dataspaceService.save(definition));
}
/**
* 启用或禁用数据空间。
*
* @param request 状态变更请求
* @return 成功结果
*/
@PostMapping("/status")
@SaCheckPermission("/api/v1/dataspace/save")
public Result<Void> status(
@JsonBody(required = true, skipConvertError = false) StatusRequest request) {
if (request == null || request.enabled() == null) {
throw new BusinessException("数据空间状态不能为空");
}
dataspaceService.setEnabled(request.id(), request.enabled());
return Result.ok();
}
/**
* 逻辑删除数据空间。
*
* @param id 数据空间 ID
* @return 成功结果
*/
@PostMapping("/remove")
@SaCheckPermission("/api/v1/dataspace/remove")
public Result<Void> remove(
@JsonBody(value = "id", required = true, skipConvertError = false) BigInteger id) {
dataspaceService.remove(id);
return Result.ok();
}
/**
* 数据空间状态变更请求。
*
* @param id 数据空间 ID
* @param enabled 是否启用
*/
public record StatusRequest(BigInteger id, Boolean enabled) {
}
}

View File

@@ -0,0 +1,86 @@
package tech.easyflow.admin.controller.dataspace;
import cn.dev33.satoken.annotation.SaCheckPermission;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import tech.easyflow.common.domain.Result;
import tech.easyflow.common.web.jsonbody.JsonBody;
import tech.easyflow.dataspace.model.DataspaceExplainResult;
import tech.easyflow.dataspace.model.DataspaceQueryRequest;
import tech.easyflow.dataspace.model.DataspaceQueryResult;
import tech.easyflow.dataspace.model.DataspaceSqlCompletionRequest;
import tech.easyflow.dataspace.model.DataspaceSqlCompletionResult;
import tech.easyflow.dataspace.service.DataspaceQueryService;
/**
* 数据空间 SQL 工作台 Query、Explain、Complete 与 Cancel API。
*/
@RestController
@RequestMapping("/api/v1/dataspaceSql")
public class DataspaceSqlController {
private final DataspaceQueryService queryService;
/**
* 创建 SQL 控制器。
*
* @param queryService 查询服务
*/
public DataspaceSqlController(DataspaceQueryService queryService) {
this.queryService = queryService;
}
/**
* 执行只读 SQL。
*
* @param request 查询请求
* @return 查询结果与指标
*/
@PostMapping("/query")
@SaCheckPermission("/api/v1/dataspaceSql/query")
public Result<DataspaceQueryResult> query(
@JsonBody(required = true, skipConvertError = false) DataspaceQueryRequest request) {
return Result.ok(queryService.query(request));
}
/**
* 显式执行非 ANALYZE Explain。
*
* @param request Explain 请求
* @return Explain 与索引信息
*/
@PostMapping("/explain")
@SaCheckPermission("/api/v1/dataspaceSql/explain")
public Result<DataspaceExplainResult> explain(
@JsonBody(required = true, skipConvertError = false) DataspaceQueryRequest request) {
return Result.ok(queryService.explain(request));
}
/**
* 返回当前数据空间内的 Calcite SQL 补全候选。
*
* @param request 补全请求
* @return 补全替换区间与候选
*/
@PostMapping("/complete")
@SaCheckPermission("/api/v1/dataspaceSql/query")
public Result<DataspaceSqlCompletionResult> complete(
@JsonBody(required = true, skipConvertError = false)
DataspaceSqlCompletionRequest request) {
return Result.ok(queryService.complete(request));
}
/**
* 尝试取消当前节点查询。
*
* @param queryId 查询 ID
* @return 是否找到并发起取消
*/
@PostMapping("/cancel")
@SaCheckPermission("/api/v1/dataspaceSql/query")
public Result<Boolean> cancel(
@JsonBody(value = "queryId", required = true, skipConvertError = false) String queryId) {
return Result.ok(queryService.cancel(queryId));
}
}

View File

@@ -1,25 +1,49 @@
package tech.easyflow.admin.controller.job; package tech.easyflow.admin.controller.job;
import cn.dev33.satoken.annotation.SaCheckPermission; import cn.dev33.satoken.annotation.SaCheckPermission;
import cn.hutool.core.date.DateUtil; import com.easyagents.flow.core.chain.Parameter;
import org.quartz.CronExpression; import com.mybatisflex.core.query.QueryWrapper;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.util.StringUtils;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
import tech.easyflow.ai.easyagentsflow.service.WorkflowRunningParameterResolver;
import tech.easyflow.ai.entity.Workflow;
import tech.easyflow.ai.enums.PublishStatus;
import tech.easyflow.ai.service.WorkflowService;
import tech.easyflow.ai.service.WorkflowUsageAuthorizationService;
import tech.easyflow.admin.model.SysJobWorkflowOptionView;
import tech.easyflow.common.constant.enums.EnumJobStatus;
import tech.easyflow.common.constant.enums.EnumJobType;
import tech.easyflow.common.constant.enums.EnumMisfirePolicy;
import tech.easyflow.common.domain.Result; import tech.easyflow.common.domain.Result;
import tech.easyflow.common.entity.LoginAccount;
import tech.easyflow.common.satoken.util.SaTokenUtil; import tech.easyflow.common.satoken.util.SaTokenUtil;
import tech.easyflow.common.web.controller.BaseCurdController; import tech.easyflow.common.web.controller.BaseCurdController;
import tech.easyflow.common.web.exceptions.BusinessException;
import tech.easyflow.common.web.jsonbody.JsonBody;
import tech.easyflow.job.entity.SysJob; import tech.easyflow.job.entity.SysJob;
import tech.easyflow.job.job.JobConstant;
import tech.easyflow.job.service.SysJobService; import tech.easyflow.job.service.SysJobService;
import tech.easyflow.job.support.SysJobWorkflowReferenceSupport;
import tech.easyflow.common.entity.LoginAccount; import tech.easyflow.log.annotation.LogRecord;
import tech.easyflow.system.enums.CategoryResourceType;
import tech.easyflow.system.enums.ResourceAction;
import tech.easyflow.system.service.ResourceAccessService;
import java.io.Serializable; import java.io.Serializable;
import java.math.BigInteger; import java.math.BigInteger;
import java.util.ArrayList; import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.util.Collection; import java.util.Collection;
import java.util.Date; import java.util.Date;
import java.util.List; import java.util.List;
import java.util.Map;
import java.util.Objects;
/** /**
* 系统任务表 控制层。 * 系统任务表 控制层。
@@ -30,52 +54,356 @@ import java.util.List;
@RestController @RestController
@RequestMapping("/api/v1/sysJob") @RequestMapping("/api/v1/sysJob")
public class SysJobController extends BaseCurdController<SysJobService, SysJob> { public class SysJobController extends BaseCurdController<SysJobService, SysJob> {
public SysJobController(SysJobService service) {
/** 工作流服务。 */
private final WorkflowService workflowService;
/** 工作流使用权限校验服务。 */
private final WorkflowUsageAuthorizationService workflowUsageAuthorizationService;
/** 资源访问控制服务。 */
private final ResourceAccessService resourceAccessService;
/** 工作流运行参数解析器。 */
private final WorkflowRunningParameterResolver workflowRunningParameterResolver;
/** 与调度计算一致的 Cron 预览格式化器。 */
private final DateTimeFormatter jobTimeFormatter;
/**
* 创建定时任务控制器。
*
* @param service 定时任务服务
* @param workflowService 工作流服务
* @param workflowUsageAuthorizationService 工作流使用权限校验服务
* @param resourceAccessService 资源访问控制服务
* @param workflowRunningParameterResolver 工作流运行参数解析器
* @param jobTimezone 定时任务业务时区
*/
public SysJobController(SysJobService service,
WorkflowService workflowService,
WorkflowUsageAuthorizationService workflowUsageAuthorizationService,
ResourceAccessService resourceAccessService,
WorkflowRunningParameterResolver workflowRunningParameterResolver,
@Value("${easyflow.job.timezone:Asia/Shanghai}") String jobTimezone) {
super(service); super(service);
this.workflowService = workflowService;
this.workflowUsageAuthorizationService = workflowUsageAuthorizationService;
this.resourceAccessService = resourceAccessService;
this.workflowRunningParameterResolver = workflowRunningParameterResolver;
this.jobTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")
.withZone(ZoneId.of(jobTimezone));
}
/**
* 获取定时任务关键字搜索字段。
*
* @return 任务名称和备注属性
*/
@Override
protected String[] getKeywordSearchProperties() {
return new String[]{"jobName", "remark"};
} }
@GetMapping("/start") @GetMapping("/start")
@SaCheckPermission("/api/v1/sysJob/save") @SaCheckPermission("/api/v1/sysJob/save")
@LogRecord("启动定时任务")
public Result<Void> start(BigInteger id) { public Result<Void> start(BigInteger id) {
LoginAccount account = SaTokenUtil.getLoginAccount();
SysJob job = requireExistingJob(id);
validateWorkflowReference(job, account);
service.startJob(id); service.startJob(id);
return Result.ok(); return Result.ok();
} }
@GetMapping("/stop") @GetMapping("/stop")
@SaCheckPermission("/api/v1/sysJob/save") @SaCheckPermission("/api/v1/sysJob/save")
@LogRecord("停止定时任务")
public Result<Void> stop(BigInteger id) { public Result<Void> stop(BigInteger id) {
service.stopJob(id); service.stopJob(id);
return Result.ok(); return Result.ok();
} }
@GetMapping("/getNextTimes") @GetMapping("/trigger")
public Result<List<String>> getNextTimes(String cronExpression) throws Exception{ @SaCheckPermission("/api/v1/sysJob/save")
CronExpression ex = new CronExpression(cronExpression); @LogRecord("立即执行定时任务")
List<String> times = new ArrayList<>(); public Result<String> trigger(BigInteger id) {
Date date = new Date(); LoginAccount account = SaTokenUtil.getLoginAccount();
for (int i = 0; i < 5; i++) { SysJob job = requireExistingJob(id);
Date next = ex.getNextValidTimeAfter(date); validateWorkflowReference(job, account);
times.add(DateUtil.formatDateTime(next)); return Result.ok(service.triggerNow(id));
date = next;
} }
return Result.ok(times);
@GetMapping("/getNextTimes")
@SaCheckPermission("/api/v1/sysJob/save")
public Result<List<String>> getNextTimes(String cronExpression) {
return Result.ok(service.nextFireTimes(cronExpression, 5).stream()
.map(Date::toInstant)
.map(jobTimeFormatter::format)
.toList());
}
@Override
@PostMapping("remove")
@Transactional(propagation = Propagation.NOT_SUPPORTED)
public Result<?> remove(@JsonBody(value = "id", required = true) Serializable id) {
service.deleteJob(List.of(id));
return Result.ok(true);
}
@Override
@PostMapping("removeBatch")
@Transactional(propagation = Propagation.NOT_SUPPORTED)
public Result<?> removeBatch(
@JsonBody(value = "ids", required = true) Collection<Serializable> ids) {
if (ids == null || ids.isEmpty()) {
return Result.fail("id不能为空");
}
service.deleteJob(ids);
return Result.ok(true);
}
/**
* 查询当前账号可用于定时任务的工作流。
*
* @return 工作流安全选项
*/
@GetMapping("/workflowOptions")
@SaCheckPermission("/api/v1/sysJob/save")
public Result<List<SysJobWorkflowOptionView>> workflowOptions() {
LoginAccount account = SaTokenUtil.getLoginAccount();
List<SysJobWorkflowOptionView> options = workflowService.list(QueryWrapper.create()
.eq(Workflow::getTenantId, account.getTenantId())
.eq(Workflow::getPublishStatus, PublishStatus.PUBLISHED.getCode())
.orderBy(Workflow::getModified, false))
.stream()
.filter(workflow -> Objects.equals(workflow.getTenantId(), account.getTenantId()))
.filter(workflow -> workflow.getPublishedSnapshotJson() != null
&& !workflow.getPublishedSnapshotJson().isEmpty())
.filter(workflow -> resourceAccessService.canAccess(
account,
CategoryResourceType.WORKFLOW,
workflow,
ResourceAction.USE))
.map(workflowService::toPublishedView)
.filter(Objects::nonNull)
.map(workflow -> new SysJobWorkflowOptionView(
workflow.getId(),
workflow.getTitle(),
workflow.getDescription()))
.toList();
return Result.ok(options);
}
/**
* 查询定时任务所选工作流的运行参数。
*
* @param id 工作流 ID
* @return 工作流运行参数
* @throws BusinessException 工作流不存在或无运行权限时抛出
*/
@GetMapping("/workflowRunningParameters")
@SaCheckPermission("/api/v1/sysJob/save")
public Result<Map<String, Object>> workflowRunningParameters(BigInteger id) {
Workflow workflow = workflowUsageAuthorizationService.requireUsableWorkflow(
id,
SaTokenUtil.getLoginAccount(),
"工作流不存在、未发布或无权运行");
Map<String, Object> result = workflowRunningParameterResolver.buildRunningParametersView(workflow);
if (result == null) {
throw new BusinessException("工作流参数配置无效,请检查工作流后重试");
}
return Result.ok(result);
} }
@Override @Override
protected Result onSaveOrUpdateBefore(SysJob entity, boolean isSave) { protected Result onSaveOrUpdateBefore(SysJob entity, boolean isSave) {
if (entity == null) {
throw new BusinessException("定时任务不能为空");
}
LoginAccount loginUser = SaTokenUtil.getLoginAccount(); LoginAccount loginUser = SaTokenUtil.getLoginAccount();
SysJob effectiveEntity = entity;
if (isSave) { if (isSave) {
// 新任务固定从 STOP 和第 0 代开始,禁止请求绕过启动协议。
entity.setStatus(EnumJobStatus.STOP.getCode());
entity.setScheduleGeneration(0L);
commonFiled(entity,loginUser.getId(),loginUser.getTenantId(), loginUser.getDeptId()); commonFiled(entity,loginUser.getId(),loginUser.getTenantId(), loginUser.getDeptId());
} else { } else {
SysJob existing = requireExistingJob(entity.getId());
preserveServerControlledFields(entity, existing);
effectiveEntity = mergeForValidation(entity, existing);
entity.setModified(new Date()); entity.setModified(new Date());
entity.setModifiedBy(loginUser.getId()); entity.setModifiedBy(loginUser.getId());
} }
validateWorkflowReference(effectiveEntity, loginUser);
validateCronExpression(effectiveEntity.getCronExpression());
validateMisfirePolicy(effectiveEntity.getMisfirePolicy());
return super.onSaveOrUpdateBefore(entity, isSave); return super.onSaveOrUpdateBefore(entity, isSave);
} }
@Override @Override
protected Result onRemoveBefore(Collection<Serializable> ids) { protected void onSaveOrUpdateAfter(SysJob entity, boolean isSave) {
service.deleteJob(ids); service.syncJob(entity.getId());
return super.onRemoveBefore(ids);
} }
@Override
@PostMapping("update")
public Result<?> update(@JsonBody SysJob entity) {
Result<?> result = onSaveOrUpdateBefore(entity, false);
if (result != null) return result;
service.updateJobDefinition(entity);
return Result.ok();
}
/**
* 校验工作流类型任务引用的工作流可被当前用户运行。
*
* @param entity 待保存的定时任务
* @param account 当前账号
* @throws BusinessException 工作流不存在、参数非法或无运行权限时抛出
*/
private void validateWorkflowReference(SysJob entity, LoginAccount account) {
if (entity == null
|| !Integer.valueOf(EnumJobType.TINY_FLOW.getCode()).equals(entity.getJobType())) {
return;
}
BigInteger workflowId = SysJobWorkflowReferenceSupport.requireWorkflowId(entity);
Workflow workflow = workflowUsageAuthorizationService.requireUsableWorkflow(
workflowId,
account,
"工作流不存在、未发布或无权运行");
validateRequiredWorkflowParams(entity, workflow);
}
/**
* 获取当前租户内存在的定时任务。
*
* @param id 定时任务 ID
* @return 已存在的定时任务
* @throws BusinessException ID 缺失或任务不存在时抛出
*/
private SysJob requireExistingJob(BigInteger id) {
if (id == null) {
throw new BusinessException("定时任务ID不能为空");
}
SysJob existing = service.getById(id);
if (existing == null) {
throw new BusinessException("定时任务不存在");
}
return existing;
}
/**
* 保留更新请求不能修改的服务端控制字段。
*
* @param entity 更新请求
* @param existing 数据库中的定时任务
*/
private void preserveServerControlledFields(SysJob entity, SysJob existing) {
entity.setTenantId(existing.getTenantId());
entity.setDeptId(existing.getDeptId());
entity.setCreated(existing.getCreated());
entity.setCreatedBy(existing.getCreatedBy());
entity.setStatus(existing.getStatus());
entity.setScheduleGeneration(existing.getScheduleGeneration());
}
/**
* 合并部分更新请求与原记录,生成用于权限和参数校验的有效任务状态。
*
* @param entity 更新请求
* @param existing 数据库中的定时任务
* @return 合并后的校验对象
*/
private SysJob mergeForValidation(SysJob entity, SysJob existing) {
SysJob effective = new SysJob();
effective.setJobType(entity.getJobType() == null
? existing.getJobType()
: entity.getJobType());
effective.setJobParams(entity.getJobParams() == null
? existing.getJobParams()
: entity.getJobParams());
effective.setCronExpression(entity.getCronExpression() == null
? existing.getCronExpression()
: entity.getCronExpression());
effective.setMisfirePolicy(entity.getMisfirePolicy() == null
? existing.getMisfirePolicy()
: entity.getMisfirePolicy());
return effective;
}
private void validateMisfirePolicy(Integer misfirePolicy) {
if (!Integer.valueOf(EnumMisfirePolicy.FIRE_ONCE_NOW.getCode()).equals(misfirePolicy)
&& !Integer.valueOf(EnumMisfirePolicy.SKIP.getCode()).equals(misfirePolicy)) {
throw new BusinessException("错过策略只支持恢复后补执行一次或跳过本次");
}
}
private void validateCronExpression(String cronExpression) {
try {
service.nextFireTimes(cronExpression, 1);
} catch (RuntimeException exception) {
throw new BusinessException(400, 1, "Cron 表达式无效", exception);
}
}
/**
* 校验定时任务已填写工作流的全部必填运行参数。
*
* @param entity 待保存定时任务
* @param workflow 关联工作流
* @throws BusinessException 工作流参数配置无效或必填值缺失时抛出
*/
private void validateRequiredWorkflowParams(SysJob entity, Workflow workflow) {
List<Parameter> parameters =
workflowRunningParameterResolver.resolveStartParameters(workflow.getContent());
if (parameters == null) {
throw new BusinessException("工作流参数配置无效,请检查工作流后重试");
}
Map<String, Object> jobParams = entity.getJobParams();
Object rawWorkflowParams = jobParams == null
? null
: jobParams.get(JobConstant.WORKFLOW_PARAMS_KEY);
Map<?, ?> workflowParams = rawWorkflowParams instanceof Map<?, ?> map
? map
: Map.of();
for (Parameter parameter : parameters) {
if (parameter == null || !parameter.isRequired()) {
continue;
}
String name = parameter.getName();
if (!StringUtils.hasText(name)) {
throw new BusinessException("工作流存在无效必填参数配置,请检查工作流后重试");
}
if (!hasRequiredValue(workflowParams.get(name))) {
String label = StringUtils.hasText(parameter.getFormLabel())
? parameter.getFormLabel()
: name;
throw new BusinessException("工作流必填参数“" + label + "”不能为空");
}
}
}
/**
* 判断必填参数值是否有效。
*
* @param value 参数值
* @return 非空时为 true
*/
private boolean hasRequiredValue(Object value) {
if (value == null) {
return false;
}
if (value instanceof CharSequence sequence) {
return StringUtils.hasText(sequence);
}
if (value instanceof Collection<?> collection) {
return !collection.isEmpty();
}
if (value instanceof Map<?, ?> map) {
return !map.isEmpty();
}
return true;
}
} }

View File

@@ -1,15 +1,29 @@
package tech.easyflow.admin.controller.job; package tech.easyflow.admin.controller.job;
import tech.easyflow.common.annotation.UsePermission; import com.mybatisflex.core.paginate.Page;
import tech.easyflow.common.domain.Result; import com.mybatisflex.core.query.QueryWrapper;
import tech.easyflow.common.satoken.util.SaTokenUtil; import jakarta.servlet.http.HttpServletRequest;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
import tech.easyflow.common.entity.LoginAccount; import tech.easyflow.common.annotation.UsePermission;
import tech.easyflow.common.domain.Result;
import tech.easyflow.common.util.StringUtil;
import tech.easyflow.common.web.controller.BaseCurdController; import tech.easyflow.common.web.controller.BaseCurdController;
import tech.easyflow.common.web.exceptions.BusinessException;
import tech.easyflow.job.entity.SysJobLog; import tech.easyflow.job.entity.SysJobLog;
import tech.easyflow.job.service.SysJobLogService; import tech.easyflow.job.service.SysJobLogService;
import java.io.Serializable;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeParseException;
import java.util.Collection;
import java.util.Date;
import java.util.List;
/** /**
* 系统任务日志 控制层。 * 系统任务日志 控制层。
* *
@@ -20,16 +34,112 @@ import tech.easyflow.job.service.SysJobLogService;
@RequestMapping("/api/v1/sysJobLog") @RequestMapping("/api/v1/sysJobLog")
@UsePermission(moduleName = "/api/v1/sysJob") @UsePermission(moduleName = "/api/v1/sysJob")
public class SysJobLogController extends BaseCurdController<SysJobLogService, SysJobLog> { public class SysJobLogController extends BaseCurdController<SysJobLogService, SysJobLog> {
public SysJobLogController(SysJobLogService service) { private static final long DEFAULT_PAGE_SIZE = 10L;
private static final long MAX_PAGE_SIZE = 100L;
private static final DateTimeFormatter QUERY_TIME_FORMATTER =
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
private final ZoneId jobZoneId;
public SysJobLogController(
SysJobLogService service,
@Value("${easyflow.job.timezone:Asia/Shanghai}") String jobTimezone) {
super(service); super(service);
this.jobZoneId = ZoneId.of(jobTimezone);
}
/**
* 构造日志筛选条件,并追加计划触发时间和实际触发时间范围。
*/
@Override
protected QueryWrapper buildQueryWrapper(HttpServletRequest request) {
QueryWrapper queryWrapper = super.buildQueryWrapper(request);
Date scheduledStart = parseQueryTime(
request.getParameter("scheduledStart"), "计划触发开始时间");
Date scheduledEnd = parseQueryTime(
request.getParameter("scheduledEnd"), "计划触发结束时间");
Date actualStart = parseQueryTime(
request.getParameter("actualStart"), "实际触发开始时间");
Date actualEnd = parseQueryTime(
request.getParameter("actualEnd"), "实际触发结束时间");
validateTimeRange(scheduledStart, scheduledEnd, "计划触发时间");
validateTimeRange(actualStart, actualEnd, "实际触发时间");
if (scheduledStart != null) {
queryWrapper.ge(SysJobLog::getScheduledFireTime, scheduledStart);
}
if (scheduledEnd != null) {
queryWrapper.le(SysJobLog::getScheduledFireTime, scheduledEnd);
}
if (actualStart != null) {
queryWrapper.ge(SysJobLog::getActualFireTime, actualStart);
}
if (actualEnd != null) {
queryWrapper.le(SysJobLog::getActualFireTime, actualEnd);
}
return queryWrapper;
}
/**
* 自动刷新只读取当前第一页,不执行分页总数统计。
*/
@GetMapping("refresh")
public Result<List<SysJobLog>> refresh(HttpServletRequest request, Long pageSize) {
QueryWrapper queryWrapper = buildQueryWrapper(request);
queryWrapper.orderBy(buildOrderBy(null, null, getDefaultOrderBy()));
queryWrapper.limit(resolvePageSize(pageSize));
return Result.ok(service.list(queryWrapper));
}
/**
* 最新计划触发记录优先,并用主键保证毫秒时间相同时顺序稳定。
*/
@Override
protected String getDefaultOrderBy() {
return "scheduled_fire_time desc, id desc";
}
@Override
protected Page<SysJobLog> queryPage(
Page<SysJobLog> page, QueryWrapper queryWrapper) {
page.setPageSize(resolvePageSize(page.getPageSize()));
return super.queryPage(page, queryWrapper);
} }
@Override @Override
protected Result onSaveOrUpdateBefore(SysJobLog entity, boolean isSave) { protected Result onSaveOrUpdateBefore(SysJobLog entity, boolean isSave) {
LoginAccount loginUser = SaTokenUtil.getLoginAccount(); throw new IllegalStateException("定时任务执行记录由系统维护,禁止外部写入");
if (isSave) { }
commonFiled(entity,loginUser.getId(),loginUser.getTenantId(), loginUser.getDeptId());
@Override
protected Result onRemoveBefore(Collection<Serializable> ids) {
service.requireTerminal(ids);
return super.onRemoveBefore(ids);
}
private long resolvePageSize(Long pageSize) {
if (pageSize == null || pageSize < 1) {
return DEFAULT_PAGE_SIZE;
}
return Math.min(pageSize, MAX_PAGE_SIZE);
}
private Date parseQueryTime(String value, String fieldName) {
if (!StringUtil.hasText(value)) {
return null;
}
try {
LocalDateTime dateTime = LocalDateTime.parse(value, QUERY_TIME_FORMATTER);
return Date.from(dateTime.atZone(jobZoneId).toInstant());
} catch (DateTimeParseException exception) {
throw new BusinessException(
400, 400, fieldName + "格式不正确", exception);
}
}
private void validateTimeRange(Date start, Date end, String fieldName) {
if (start != null && end != null && start.after(end)) {
throw new BusinessException(400, 400, fieldName + "范围不正确");
} }
return super.onSaveOrUpdateBefore(entity, isSave);
} }
} }

View File

@@ -0,0 +1,241 @@
package tech.easyflow.admin.controller.skill;
import cn.dev33.satoken.annotation.SaCheckPermission;
import com.mybatisflex.core.query.QueryWrapper;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import tech.easyflow.common.annotation.UsePermission;
import tech.easyflow.common.domain.Result;
import tech.easyflow.common.entity.LoginAccount;
import tech.easyflow.common.satoken.util.SaTokenUtil;
import tech.easyflow.common.util.SearchKeywordUtil;
import tech.easyflow.common.web.exceptions.BusinessException;
import tech.easyflow.common.web.jsonbody.JsonBody;
import tech.easyflow.skill.entity.SkillCategory;
import tech.easyflow.skill.service.SkillCategoryService;
import tech.easyflow.system.entity.vo.RoleCategoryAccessSnapshot;
import tech.easyflow.system.enums.CategoryResourceType;
import tech.easyflow.system.service.CategoryPermissionService;
import java.io.Serializable;
import java.math.BigInteger;
import java.util.List;
import java.util.LinkedHashMap;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
/**
* Skill 分类管理控制器。
*/
@RestController
@RequestMapping("/api/v1/skill/category")
@UsePermission(moduleName = "/api/v1/skill")
public class SkillCategoryController {
private static final Set<String> SORT_COLUMNS = Set.of(
"id", "category_name", "parent_id", "level_no", "sort_no", "status", "created", "modified");
private final SkillCategoryService service;
@javax.annotation.Resource
private CategoryPermissionService categoryPermissionService;
/**
* 创建 Skill 分类管理控制器。
*
* @param service Skill 分类服务
*/
public SkillCategoryController(SkillCategoryService service) {
this.service = service;
}
/**
* 查询当前用户可见的 Skill 分类。
*
* @param entity 查询条件
* @param asTree 是否转树
* @param sortKey 排序字段
* @param sortType 排序方式
* @return 可见分类列表
*/
@GetMapping("visibleList")
@SaCheckPermission("/api/v1/skill/query")
public Result<List<SkillCategory>> visibleList(SkillCategory entity, Boolean asTree, String sortKey, String sortType) {
QueryWrapper queryWrapper = QueryWrapper.create()
.eq(SkillCategory::getTenantId, currentAccount().getTenantId());
if (entity != null) {
queryWrapper.eq(SkillCategory::getId, entity.getId(), entity.getId() != null)
.eq(SkillCategory::getParentId, entity.getParentId(), entity.getParentId() != null)
.eq(SkillCategory::getLevelNo, entity.getLevelNo(), entity.getLevelNo() != null)
.eq(SkillCategory::getStatus, entity.getStatus(), entity.getStatus() != null);
if (entity.getCategoryName() != null && !entity.getCategoryName().isBlank()) {
queryWrapper.and("category_name LIKE ?",
SearchKeywordUtil.literalContainsPattern(entity.getCategoryName()));
}
}
RoleCategoryAccessSnapshot access = categoryPermissionService.getCurrentAccess(CategoryResourceType.SKILL.getCode());
queryWrapper.orderBy(resolveOrderBy(sortKey, sortType));
List<SkillCategory> categories = service.list(queryWrapper);
if (access.isRestricted()) {
Set<BigInteger> visibleIds = new java.util.LinkedHashSet<>(access.getCategoryIds());
categories.stream().filter(category -> access.getCategoryIds().contains(category.getId()))
.map(SkillCategory::getAncestors).filter(value -> value != null && !value.isBlank())
.flatMap(value -> java.util.Arrays.stream(value.split(",")))
.map(String::trim).filter(value -> !value.isBlank() && !"0".equals(value))
.map(BigInteger::new).forEach(visibleIds::add);
categories = categories.stream().filter(category -> visibleIds.contains(category.getId())).toList();
}
return Result.ok(Boolean.FALSE.equals(asTree) ? categories : toTree(categories));
}
/**
* 查询当前租户完整分类管理树,包含停用分类。
*
* @return 分类树
*/
@GetMapping("tree")
@SaCheckPermission("/api/v1/skill/category")
public Result<List<SkillCategory>> tree() {
List<SkillCategory> categories = service.list(QueryWrapper.create()
.eq(SkillCategory::getTenantId, currentAccount().getTenantId())
.orderBy("sort_no asc, id asc"));
return Result.ok(toTree(categories));
}
/**
* 移动 Skill 分类到新的父级。
*
* @param id 分类 ID
* @param parentId 新父级 ID根分类为空
* @return 更新结果
*/
@PostMapping("move")
@SaCheckPermission("/api/v1/skill/category")
public Result<?> move(
@JsonBody(value = "id", required = true, skipConvertError = false) BigInteger id,
@JsonBody(value = "parentId", skipConvertError = false) BigInteger parentId) {
SkillCategory category = service.getOne(QueryWrapper.create()
.eq(SkillCategory::getId, id)
.eq(SkillCategory::getTenantId, currentAccount().getTenantId()));
if (category == null) {
throw new BusinessException(404, 404, "Skill 分类不存在");
}
category.setParentId(parentId);
if (!service.updateById(category)) {
throw new BusinessException(500, 500, "移动 Skill 分类失败,请稍后重试");
}
return Result.ok();
}
/**
* 创建 Skill 分类。
*
* @param entity 分类
* @return 保存结果
*/
@PostMapping("save")
@SaCheckPermission("/api/v1/skill/category")
public Result<?> save(@JsonBody(required = true, skipConvertError = false) SkillCategory entity) {
if (entity != null) {
entity.setId(null);
entity.setTenantId(null);
entity.setAncestors(null);
entity.setLevelNo(null);
entity.setCreated(null);
entity.setCreatedBy(null);
entity.setModified(null);
entity.setModifiedBy(null);
}
if (!service.save(entity)) {
throw new BusinessException(500, 500, "创建 Skill 分类失败,请稍后重试");
}
return Result.ok(entity);
}
/**
* 更新 Skill 分类。
*
* @param entity 分类
* @return 更新结果
*/
@PostMapping("update")
@SaCheckPermission("/api/v1/skill/category")
public Result<?> update(@JsonBody(required = true, skipConvertError = false) SkillCategory entity) {
if (entity != null) {
entity.setTenantId(null);
entity.setAncestors(null);
entity.setLevelNo(null);
entity.setCreated(null);
entity.setCreatedBy(null);
entity.setModified(null);
entity.setModifiedBy(null);
}
if (entity == null || entity.getId() == null) {
throw new BusinessException("Skill 分类 ID 不能为空");
}
if (!service.updateById(entity)) {
throw new BusinessException(500, 500, "更新 Skill 分类失败,请稍后重试");
}
return Result.ok(entity);
}
/**
* 删除 Skill 分类。
*
* @param id 分类 ID
* @return 删除结果
*/
@PostMapping("remove")
@SaCheckPermission("/api/v1/skill/category")
public Result<?> remove(
@JsonBody(value = "id", required = true, skipConvertError = false) Serializable id) {
if (!service.removeById(id)) {
throw new BusinessException(500, 500, "删除 Skill 分类失败,请稍后重试");
}
return Result.ok();
}
private LoginAccount currentAccount() {
LoginAccount account = SaTokenUtil.getLoginAccount();
if (account == null || account.getId() == null || account.getTenantId() == null) {
throw new BusinessException(401, 401, "未登录或登录态无效");
}
return account;
}
/**
* 将分类排序参数收敛到固定字段白名单,禁止原始 SQL 片段进入查询。
*
* @param sortKey 排序字段
* @param sortType 排序方向
* @return 安全排序表达式
*/
String resolveOrderBy(String sortKey, String sortType) {
String snake = sortKey == null ? "" : sortKey
.replaceAll("([a-z0-9])([A-Z])", "$1_$2")
.toLowerCase(Locale.ROOT);
String column = SORT_COLUMNS.contains(snake) ? snake : "sort_no";
String direction = "desc".equalsIgnoreCase(sortType) ? "desc" : "asc";
return column + " " + direction + ("id".equals(column) ? "" : ", id asc");
}
private List<SkillCategory> toTree(List<SkillCategory> categories) {
Map<java.math.BigInteger, SkillCategory> byId = new LinkedHashMap<>();
categories.forEach(category -> {
category.setChildren(null);
byId.put(category.getId(), category);
});
List<SkillCategory> roots = new java.util.ArrayList<>();
for (SkillCategory category : categories) {
SkillCategory parent = category.getParentId() == null ? null : byId.get(category.getParentId());
if (parent == null) {
roots.add(category);
} else {
parent.getChildren().add(category);
}
}
return roots;
}
}

View File

@@ -0,0 +1,755 @@
package tech.easyflow.admin.controller.skill;
import cn.dev33.satoken.annotation.SaCheckPermission;
import cn.dev33.satoken.annotation.SaMode;
import com.mybatisflex.core.paginate.Page;
import com.mybatisflex.core.query.QueryWrapper;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.http.MediaType;
import org.springframework.util.StreamUtils;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.bind.annotation.RequestPart;
import org.springframework.web.multipart.MultipartFile;
import tech.easyflow.admin.controller.ai.support.AiResourceCreatorNameSupport;
import tech.easyflow.admin.controller.skill.vo.SkillCopyRequest;
import tech.easyflow.admin.controller.skill.vo.SkillDraftRequest;
import tech.easyflow.admin.controller.skill.vo.SkillGitRepositoryPrepareRequest;
import tech.easyflow.admin.controller.skill.vo.SkillGitRepositoryScanRequest;
import tech.easyflow.admin.controller.skill.vo.SkillImportBatchResultView;
import tech.easyflow.admin.controller.skill.vo.SkillView;
import tech.easyflow.admin.controller.skill.vo.SkillToolBindingUpdateRequest;
import tech.easyflow.admin.controller.skill.vo.SkillPublishStatusView;
import tech.easyflow.approval.entity.vo.ApprovalActionResult;
import tech.easyflow.common.entity.LoginAccount;
import tech.easyflow.common.domain.Result;
import tech.easyflow.common.satoken.util.SaTokenUtil;
import tech.easyflow.common.util.SearchKeywordUtil;
import tech.easyflow.common.web.exceptions.BusinessException;
import tech.easyflow.common.web.jsonbody.JsonBody;
import tech.easyflow.skill.entity.Skill;
import tech.easyflow.skill.file.SkillFileContent;
import tech.easyflow.skill.file.SkillFileNode;
import tech.easyflow.skill.file.SkillFileRenameRequest;
import tech.easyflow.skill.file.SkillFileSaveRequest;
import tech.easyflow.skill.file.SkillFileService;
import tech.easyflow.skill.imports.SkillExportRequest;
import tech.easyflow.skill.imports.SkillExportArtifact;
import tech.easyflow.skill.imports.SkillExportService;
import tech.easyflow.skill.imports.SkillImportConfirmRequest;
import tech.easyflow.skill.imports.SkillImportPreview;
import tech.easyflow.skill.imports.SkillImportService;
import tech.easyflow.skill.gitimport.SkillGitImportService;
import tech.easyflow.skill.gitimport.SkillGitScanResult;
import tech.easyflow.skill.publish.SkillPublishAppService;
import tech.easyflow.skill.security.SkillVisibilityQueryHelper;
import tech.easyflow.skill.service.SkillApprovalStateService;
import tech.easyflow.skill.service.SkillService;
import tech.easyflow.skill.service.SkillToolBindingService;
import tech.easyflow.skill.service.SkillToolOptionQueryService;
import tech.easyflow.skill.vo.SkillMcpToolManifestView;
import tech.easyflow.skill.vo.SkillToolOptionPage;
import tech.easyflow.skill.validation.SkillValidationResult;
import tech.easyflow.system.enums.CategoryResourceType;
import tech.easyflow.system.enums.ResourceAction;
import tech.easyflow.system.service.CategoryPermissionService;
import tech.easyflow.system.service.ResourceAccessService;
import java.io.IOException;
import java.io.InputStream;
import java.math.BigInteger;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.Locale;
import java.util.Objects;
import java.util.Set;
/**
* Skill 管理端 API统一负责轻量查询、白名单写入、文件工作台和标准包导入导出。
*/
@RestController
@RequestMapping("/api/v1/skill")
public class SkillController {
private static final Set<String> PAGE_SORT_COLUMNS = Set.of(
"id", "name", "display_name", "created", "modified", "publish_status");
private final SkillService skillService;
private final SkillApprovalStateService skillApprovalStateService;
private final SkillPublishAppService skillPublishAppService;
private final SkillImportService skillImportService;
private final SkillGitImportService skillGitImportService;
private final SkillExportService skillExportService;
private final SkillFileService skillFileService;
private final SkillToolBindingService skillToolBindingService;
private final SkillToolOptionQueryService skillToolOptionQueryService;
private final ResourceAccessService resourceAccessService;
private final CategoryPermissionService categoryPermissionService;
private final SkillVisibilityQueryHelper visibilityQueryHelper;
private final AiResourceCreatorNameSupport creatorNameSupport;
/**
* 创建 Skill 管理控制器。
*
* @param skillService Skill 服务
* @param skillApprovalStateService 审批状态服务
* @param skillPublishAppService 发布服务
* @param skillImportService 导入服务
* @param skillGitImportService Git 仓库导入服务
* @param skillExportService 导出服务
* @param skillFileService 文件服务
* @param skillToolBindingService Skill Tool 绑定服务
* @param skillToolOptionQueryService Skill Tool 候选查询服务
* @param resourceAccessService 资源权限服务
* @param categoryPermissionService 分类权限服务
* @param visibilityQueryHelper 可见性查询助手
* @param creatorNameSupport 创建人名称助手
*/
public SkillController(SkillService skillService,
SkillApprovalStateService skillApprovalStateService,
SkillPublishAppService skillPublishAppService,
SkillImportService skillImportService,
SkillGitImportService skillGitImportService,
SkillExportService skillExportService,
SkillFileService skillFileService,
SkillToolBindingService skillToolBindingService,
SkillToolOptionQueryService skillToolOptionQueryService,
ResourceAccessService resourceAccessService,
CategoryPermissionService categoryPermissionService,
SkillVisibilityQueryHelper visibilityQueryHelper,
AiResourceCreatorNameSupport creatorNameSupport) {
this.skillService = skillService;
this.skillApprovalStateService = skillApprovalStateService;
this.skillPublishAppService = skillPublishAppService;
this.skillImportService = skillImportService;
this.skillGitImportService = skillGitImportService;
this.skillExportService = skillExportService;
this.skillFileService = skillFileService;
this.skillToolBindingService = skillToolBindingService;
this.skillToolOptionQueryService = skillToolOptionQueryService;
this.resourceAccessService = resourceAccessService;
this.categoryPermissionService = categoryPermissionService;
this.visibilityQueryHelper = visibilityQueryHelper;
this.creatorNameSupport = creatorNameSupport;
}
/**
* 分页查询当前用户可读的 Skill 描述信息。
*
* @param pageNumber 页码
* @param pageSize 每页数量
* @param categoryId 分类 ID
* @param categoryScope 分类范围UNCATEGORIZED 表示未分类
* @param name 名称关键词
* @param displayName 展示名称关键词
* @param keyword 名称、用途或创建人模糊关键词
* @param publishStatus 发布状态
* @param visibilityScope 使用范围
* @param sortKey 排序字段
* @param sortType 排序方向
* @return 轻量分页结果
*/
@GetMapping("/page")
@SaCheckPermission("/api/v1/skill/query")
public Result<Page<SkillView>> page(Long pageNumber, Long pageSize, BigInteger categoryId, String categoryScope,
String name, String displayName, String keyword, String publishStatus,
String visibilityScope, String sortKey, String sortType) {
long normalizedPage = pageNumber == null || pageNumber < 1 ? 1 : pageNumber;
long normalizedSize = pageSize == null || pageSize < 1 ? 10 : Math.min(pageSize, 100);
QueryWrapper query = descriptorQuery();
visibilityQueryHelper.applyReadableAccess(query);
if ("UNCATEGORIZED".equalsIgnoreCase(categoryScope)) {
query.isNull("category_id");
} else {
query.eq("category_id", categoryId, categoryId != null);
}
query.eq("publish_status", publishStatus, hasText(publishStatus))
.eq("visibility_scope", visibilityScope, hasText(visibilityScope));
String effectiveKeyword = hasText(keyword) ? keyword : hasText(displayName) ? displayName : name;
if (hasText(effectiveKeyword)) {
String pattern = SearchKeywordUtil.literalContainsPattern(effectiveKeyword);
query.and("(name LIKE ? ESCAPE '\\\\' OR display_name LIKE ? ESCAPE '\\\\' "
+ "OR description LIKE ? ESCAPE '\\\\' OR EXISTS (SELECT 1 FROM tb_sys_account a "
+ "WHERE a.id = tb_skill.created_by AND a.tenant_id = tb_skill.tenant_id "
+ "AND (a.nickname LIKE ? ESCAPE '\\\\' OR a.login_name LIKE ? ESCAPE '\\\\'))) ",
pattern, pattern, pattern, pattern, pattern);
}
query.orderBy(resolveSortColumn(sortKey) + ("asc".equalsIgnoreCase(sortType) ? " asc" : " desc"));
Page<Skill> source = skillService.page(new Page<>(normalizedPage, normalizedSize), query);
fillListState(source.getRecords());
LoginAccount account = SaTokenUtil.getLoginAccount();
boolean superAdmin = account != null && categoryPermissionService.isSuperAdmin(account);
List<SkillView> records = source.getRecords().stream()
.map(skill -> toPageView(skill, account, superAdmin)).toList();
return Result.ok(new Page<>(records, source.getPageNumber(), source.getPageSize(), source.getTotalRow()));
}
/**
* 获取 Skill 完整管理详情。
*
* @param id Skill ID
* @return Skill 详情
*/
@GetMapping("/detail")
@SaCheckPermission("/api/v1/skill/getDetail")
public Result<SkillView> detail(BigInteger id) {
Skill skill = skillService.getManagementDetail(id);
fillListState(List.of(skill));
return Result.ok(toView(skill));
}
/**
* 查询 Skill 可绑定的 Tool 候选。
*
* @param keyword 名称或描述关键词
* @param toolType 类型过滤
* @param pageNum 页码
* @param pageSize 每页数量
* @return 安全候选分页
*/
@GetMapping("/toolOptions")
@SaCheckPermission(value = {"/api/v1/skill/save", "/api/v1/skill/update"}, mode = SaMode.OR)
public Result<SkillToolOptionPage> toolOptions(String keyword, String toolType,
Long pageNum, Long pageSize) {
return Result.ok(skillToolOptionQueryService.page(keyword, toolType,
pageNum == null ? 1 : pageNum, pageSize == null ? 20 : pageSize));
}
/**
* 按需读取指定 MCP 的脱敏 Tool 清单。
*
* @param mcpId MCP ID
* @return MCP Tool 清单
*/
@GetMapping("/mcpTools")
@SaCheckPermission(value = {"/api/v1/skill/save", "/api/v1/skill/update"}, mode = SaMode.OR)
public Result<SkillMcpToolManifestView> mcpTools(BigInteger mcpId) {
return Result.ok(skillToolOptionQueryService.mcpTools(mcpId));
}
/**
* 原子替换 Skill 的全部平台 Tool 草稿绑定。
*
* @param request 白名单绑定请求
* @return 服务端规范化的安全绑定摘要
*/
@PostMapping("/toolBinding/update")
@SaCheckPermission(value = {"/api/v1/skill/save", "/api/v1/skill/update"}, mode = SaMode.OR)
public Result<List<SkillView.ToolBindingView>> updateToolBindings(
@JsonBody(required = true, skipConvertError = false) SkillToolBindingUpdateRequest request) {
if (request == null || request.getSkillId() == null) {
throw new BusinessException("Skill ID 不能为空");
}
List<tech.easyflow.skill.entity.SkillToolBinding> bindings = request.getBindings() == null
? List.of() : request.getBindings().stream().map(SkillToolBindingUpdateRequest.Binding::toEntity).toList();
return Result.ok(skillToolBindingService.replaceBindings(request.getSkillId(), bindings)
.stream().map(SkillView.ToolBindingView::from).toList());
}
/**
* 创建 Skill 草稿。
*
* @param request 草稿白名单请求
* @return 创建后的 Skill
*/
@PostMapping("/save")
@SaCheckPermission("/api/v1/skill/save")
public Result<SkillView> save(@JsonBody(required = true, skipConvertError = false) SkillDraftRequest request) {
if (request == null || request.id() != null) {
throw new BusinessException("创建 Skill 时不能指定 ID");
}
return Result.ok(toView(skillService.saveDraft(request.toEntity())));
}
/**
* 更新 Skill 草稿。
*
* @param request 草稿白名单请求
* @return 更新后的 Skill
*/
@PostMapping("/update")
@SaCheckPermission("/api/v1/skill/update")
public Result<SkillView> update(@JsonBody(required = true, skipConvertError = false) SkillDraftRequest request) {
if (request == null || request.id() == null) {
throw new BusinessException("Skill ID 不能为空");
}
return Result.ok(toView(skillService.updateDraft(request.toUpdateEntity())));
}
/**
* 复制已有 Skill 为当前用户拥有的新草稿。
*
* @param request 复制请求
* @return 新建的 Skill 草稿
*/
@PostMapping("/copy")
@SaCheckPermission("/api/v1/skill/save")
public Result<SkillView> copy(@JsonBody(required = true, skipConvertError = false) SkillCopyRequest request) {
if (request == null) {
throw new BusinessException("复制参数不能为空");
}
return Result.ok(toView(skillService.copyDraft(request.sourceId(), request.name(),
request.displayName(), request.categoryId())));
}
/**
* 在展示发布确认前执行发布级全量校验。
*
* @param id Skill ID
* @return 标准包结构化校验结果
*/
@PostMapping("/validatePublish")
@SaCheckPermission("/api/v1/skill/submitPublishApproval")
public Result<SkillValidationResult> validatePublish(
@JsonBody(value = "id", required = true, skipConvertError = false) BigInteger id) {
return Result.ok(skillService.validateSkill(id, true));
}
/**
* 获取 Skill 文件树。
*
* @param skillId Skill ID
* @return 文件树
*/
@GetMapping("/file/tree")
@SaCheckPermission("/api/v1/skill/getDetail")
public Result<List<SkillFileNode>> fileTree(BigInteger skillId) {
return Result.ok(skillFileService.tree(skillId));
}
/**
* 获取 Skill 文本文件内容或二进制摘要。
*
* @param skillId Skill ID
* @param path 包内路径
* @return 文件内容
*/
@GetMapping("/file/content")
@SaCheckPermission("/api/v1/skill/getDetail")
public Result<SkillFileContent> fileContent(BigInteger skillId, String path) {
return Result.ok(skillFileService.getContent(skillId, path));
}
/**
* 保存已有文本文件。
*
* @param request 保存请求
* @return 最新文件内容
*/
@PostMapping("/file/save")
@SaCheckPermission("/api/v1/skill/file")
public Result<SkillFileContent> saveFile(
@JsonBody(required = true, skipConvertError = false) SkillFileSaveRequest request) {
return Result.ok(skillFileService.saveContent(request));
}
/**
* 创建文本文件。
*
* @param request 创建请求
* @return 文件内容
*/
@PostMapping("/file/create")
@SaCheckPermission("/api/v1/skill/file")
public Result<SkillFileContent> createFile(
@JsonBody(required = true, skipConvertError = false) SkillFileSaveRequest request) {
return Result.ok(skillFileService.createTextFile(request));
}
/**
* 重命名文件。
*
* @param request 重命名请求
* @return 最新文件内容
*/
@PostMapping("/file/rename")
@SaCheckPermission("/api/v1/skill/file")
public Result<SkillFileContent> renameFile(
@JsonBody(required = true, skipConvertError = false) SkillFileRenameRequest request) {
return Result.ok(skillFileService.renameFile(request));
}
/**
* 删除包内文件。
*
* @param skillId Skill ID
* @param path 文件路径
* @return 空结果
*/
@PostMapping("/file/delete")
@SaCheckPermission("/api/v1/skill/file")
public Result<Void> deleteFile(
@JsonBody(value = "skillId", required = true, skipConvertError = false) BigInteger skillId,
@JsonBody(value = "path", required = true, skipConvertError = false) String path,
@JsonBody(value = "expectedContentHash", required = true, skipConvertError = false)
String expectedContentHash) {
skillFileService.deleteFile(skillId, path, expectedContentHash);
return Result.ok();
}
/**
* 上传任意包内二进制资源。
*
* @param skillId Skill ID
* @param path 文件路径
* @param expectedContentHash 目标文件预期内容哈希,替换时必填
* @param file 上传文件
* @return 文件摘要
*/
@PostMapping(value = "/file/upload", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@SaCheckPermission("/api/v1/skill/file")
public Result<SkillFileContent> uploadFile(BigInteger skillId,
String path,
String expectedContentHash,
MultipartFile file) {
return Result.ok(skillFileService.uploadResource(skillId, path, file, expectedContentHash));
}
/**
* 下载包内文件。
*
* @param skillId Skill ID
* @param path 文件路径
* @param response HTTP 响应
* @throws IOException 响应写入失败
*/
@GetMapping("/file/download")
@SaCheckPermission("/api/v1/skill/getDetail")
public void downloadFile(BigInteger skillId, String path, HttpServletResponse response) throws IOException {
transferFile(skillId, path, response, false);
}
/**
* 安全预览包内文件;主动内容强制下载。
*
* @param skillId Skill ID
* @param path 文件路径
* @param response HTTP 响应
* @throws IOException 响应写入失败
*/
@GetMapping("/file/preview")
@SaCheckPermission("/api/v1/skill/getDetail")
public void previewFile(BigInteger skillId, String path, HttpServletResponse response) throws IOException {
transferFile(skillId, path, response, true);
}
/**
* 批量预览标准 ZIP 导入内容。
*
* @param files 导入文件
* @param file 兼容单文件字段
* @return 每个文件的 token 化预览
*/
@PostMapping(value = "/import/preview", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@SaCheckPermission("/api/v1/skill/import")
public Result<List<SkillImportPreview>> importPreview(
@RequestPart(value = "files", required = false) List<MultipartFile> files,
@RequestPart(value = "file", required = false) MultipartFile file) {
List<MultipartFile> uploads = new java.util.ArrayList<>(files == null ? List.of() : files);
if (file != null) {
uploads.add(file);
}
if (uploads.isEmpty()) {
throw new BusinessException("请选择要导入的标准 Skill ZIP");
}
if (uploads.size() > SkillImportService.MAX_BATCH_SKILL_COUNT) {
throw new BusinessException("单次最多选择 "
+ SkillImportService.MAX_BATCH_SKILL_COUNT + " 个 Skill ZIP");
}
return Result.ok(skillImportService.previewBatch(uploads));
}
/**
* 扫描 HTTPS Git 仓库中的标准 Skill 候选。
*
* @param request 仓库地址请求
* @return 固定提交的候选列表
*/
@PostMapping("/import/repository/scan")
@SaCheckPermission("/api/v1/skill/import")
public Result<SkillGitScanResult> scanGitRepository(
@JsonBody(required = true, skipConvertError = false) SkillGitRepositoryScanRequest request) {
if (request == null) {
throw new BusinessException("请输入 Git 仓库地址");
}
return Result.ok(skillGitImportService.scan(request.repositoryUrl()));
}
/**
* 将选中的 Git Skill 候选转换为既有标准 ZIP 导入预览。
*
* @param request 扫描令牌与候选 ID
* @return 与本地 ZIP 导入一致的预览列表
*/
@PostMapping("/import/repository/prepare")
@SaCheckPermission("/api/v1/skill/import")
public Result<List<SkillImportPreview>> prepareGitRepositoryImport(
@JsonBody(required = true, skipConvertError = false) SkillGitRepositoryPrepareRequest request) {
if (request == null) {
throw new BusinessException("请选择要导入的 Git Skill");
}
return Result.ok(skillGitImportService.prepare(request.scanToken(), request.candidateIds()));
}
/**
* 使用一次性 token 确认导入。
*
* @param request 导入确认请求
* @return 导入后的 Skill
*/
@PostMapping("/import/confirm")
@SaCheckPermission("/api/v1/skill/import")
public Result<List<SkillView>> importConfirm(
@JsonBody(required = true, skipConvertError = false) SkillImportConfirmRequest request) {
return Result.ok(skillImportService.confirm(request).stream().map(this::toView).toList());
}
/**
* 独立确认多个已预检的标准 Skill ZIP单包业务失败不回滚其他包。
*
* @param requests 导入确认请求,按预检 token 一一对应
* @return 各包独立导入结果
*/
@PostMapping("/import/confirmBatch")
@SaCheckPermission("/api/v1/skill/import")
public Result<List<SkillImportBatchResultView>> importConfirmBatch(
@JsonBody(required = true, skipConvertError = false) List<SkillImportConfirmRequest> requests) {
if (requests == null || requests.isEmpty()) {
throw new BusinessException("请选择要确认导入的 Skill");
}
if (requests.size() > SkillImportService.MAX_BATCH_SKILL_COUNT) {
throw new BusinessException("单次最多确认导入 "
+ SkillImportService.MAX_BATCH_SKILL_COUNT + " 个 Skill");
}
List<SkillImportBatchResultView> results = new java.util.ArrayList<>(requests.size());
for (SkillImportConfirmRequest request : requests) {
String token = request == null ? null : request.getImportToken();
try {
List<SkillView> skills = skillImportService.confirm(request).stream().map(this::toView).toList();
results.add(SkillImportBatchResultView.succeeded(token, skills));
} catch (BusinessException exception) {
results.add(SkillImportBatchResultView.failed(token, exception.getMessage()));
}
}
return Result.ok(results);
}
/**
* 取消导入并清理临时包。
*
* @param importToken 导入 token
* @return 空结果
*/
@PostMapping("/import/cancel")
@SaCheckPermission("/api/v1/skill/import")
public Result<Void> importCancel(
@JsonBody(value = "importToken", required = true, skipConvertError = false) String importToken) {
skillImportService.cancel(importToken);
return Result.ok();
}
/**
* 导出标准 Skill ZIP。
*
* @param request 导出请求
* @param response HTTP 响应
*/
@PostMapping("/export")
@SaCheckPermission("/api/v1/skill/export")
public void export(@JsonBody(required = true, skipConvertError = false) SkillExportRequest request,
HttpServletResponse response) {
if (request == null || request.getIds().isEmpty()) {
throw new BusinessException("请选择要导出的 Skill");
}
if (request.getIds().size() > 100) {
throw new BusinessException("单次最多导出 100 个 Skill");
}
try (SkillExportArtifact artifact = skillExportService.prepare(request.getIds())) {
response.setContentType(artifact.getMediaType());
response.setHeader("Content-Disposition", attachment(artifact.getFileName()));
artifact.transferTo(output(response));
}
}
/**
* 导出单个标准 Skill 包。
*
* @param id Skill ID
* @param response HTTP 响应
*/
@GetMapping("/export")
@SaCheckPermission("/api/v1/skill/export")
public void exportOne(BigInteger id, HttpServletResponse response) {
if (id == null) {
throw new BusinessException("Skill ID 不能为空");
}
writeExport(List.of(id), response);
}
/**
* 提交发布审批。
*
* @param id Skill ID
* @param applicationReason 发布说明
* @return 审批实例 ID
*/
@PostMapping("/submitPublishApproval")
@SaCheckPermission("/api/v1/skill/submitPublishApproval")
public Result<BigInteger> submitPublishApproval(
@JsonBody(value = "id", required = true, skipConvertError = false) BigInteger id,
@JsonBody(value = "applicationReason", required = true, skipConvertError = false)
String applicationReason) {
return approvalResult(skillPublishAppService.submitPublishApproval(id, applicationReason),
"已提交发布审批", "已直接发布");
}
/**
* 提交下线审批。
*
* @param id Skill ID
* @return 审批实例 ID
*/
@PostMapping("/submitOfflineApproval")
@SaCheckPermission("/api/v1/skill/submitOfflineApproval")
public Result<BigInteger> submitOfflineApproval(
@JsonBody(value = "id", required = true, skipConvertError = false) BigInteger id) {
return approvalResult(skillPublishAppService.submitOfflineApproval(id), "已提交下线审批", "已直接下线");
}
/**
* 提交删除审批。
*
* @param id Skill ID
* @return 审批实例 ID
*/
@PostMapping("/submitDeleteApproval")
@SaCheckPermission("/api/v1/skill/submitDeleteApproval")
public Result<BigInteger> submitDeleteApproval(
@JsonBody(value = "id", required = true, skipConvertError = false) BigInteger id) {
return approvalResult(skillPublishAppService.submitDeleteApproval(id), "已提交删除审批", "已直接删除");
}
/**
* 查询 Skill 发布和审批派生状态。
*
* @param id Skill ID
* @return 发布状态
*/
@GetMapping("/publish/status")
@SaCheckPermission("/api/v1/skill/getDetail")
public Result<SkillPublishStatusView> publishStatus(BigInteger id) {
QueryWrapper query = descriptorQuery().eq(Skill::getId, id);
visibilityQueryHelper.applyReadableAccess(query);
Skill skill = skillService.getOne(query);
if (skill == null) {
throw new BusinessException(404, 404, "Skill 不存在");
}
fillListState(List.of(skill));
return Result.ok(new SkillPublishStatusView(skill.getId(), skill.getPublishStatus(),
skill.getApprovalPending(), skill.getCurrentApprovalActionType(), skill.getDisplayPublishStatus(),
skill.getCurrentApprovalInstanceId()));
}
private QueryWrapper descriptorQuery() {
return QueryWrapper.create().select("id", "tenant_id", "dept_id", "category_id", "name", "display_name", "description",
"visibility_scope", "package_hash", "snapshot_hash",
"publish_status", "current_approval_instance_id", "created", "created_by", "modified", "modified_by");
}
private void writeExport(List<BigInteger> ids, HttpServletResponse response) {
try (SkillExportArtifact artifact = skillExportService.prepare(ids)) {
response.setContentType(artifact.getMediaType());
response.setHeader("Content-Disposition", attachment(artifact.getFileName()));
artifact.transferTo(output(response));
}
}
private void fillListState(List<Skill> skills) {
skillApprovalStateService.fillSkillApprovalState(skills);
creatorNameSupport.fillSkillCreatorNames(skills);
}
private SkillView toView(Skill skill) {
boolean readable = resourceAccessService.canAccess(CategoryResourceType.SKILL, skill, ResourceAction.READ);
boolean manageable = resourceAccessService.canAccess(CategoryResourceType.SKILL, skill, ResourceAction.MANAGE);
return SkillView.from(skill, readable, manageable);
}
private SkillView toPageView(Skill skill, LoginAccount account, boolean superAdmin) {
boolean sameTenant = account != null && account.getTenantId() != null
&& Objects.equals(account.getTenantId(), skill.getTenantId());
boolean manageable = sameTenant && (superAdmin || Objects.equals(account.getId(), skill.getCreatedBy()));
return SkillView.from(skill, sameTenant, manageable);
}
private void transferFile(BigInteger skillId, String path, HttpServletResponse response, boolean preview) throws IOException {
SkillFileContent content = skillFileService.getContent(skillId, path);
String mediaType = content.getMediaType() == null ? MediaType.APPLICATION_OCTET_STREAM_VALUE : content.getMediaType();
boolean inline = preview && isSafeInline(mediaType);
response.setContentType(inline ? mediaType : MediaType.APPLICATION_OCTET_STREAM_VALUE);
response.setHeader("X-Content-Type-Options", "nosniff");
response.setHeader("Content-Security-Policy", "sandbox; default-src 'none'");
response.setHeader("Content-Disposition", (inline ? "inline" : "attachment") + filenameParameter(fileName(path)));
if (Boolean.TRUE.equals(content.getIsText())) {
response.getOutputStream().write((content.getContent() == null ? "" : content.getContent())
.getBytes(StandardCharsets.UTF_8));
return;
}
try (InputStream inputStream = skillFileService.openResource(skillId, path)) {
StreamUtils.copy(inputStream, response.getOutputStream());
}
}
private boolean isSafeInline(String mediaType) {
String normalized = mediaType.toLowerCase(Locale.ROOT).split(";", 2)[0];
return normalized.equals("application/pdf") || normalized.equals("text/plain")
|| normalized.equals("text/markdown") || normalized.equals("image/png")
|| normalized.equals("image/jpeg") || normalized.equals("image/gif")
|| normalized.equals("image/webp") || normalized.equals("image/avif");
}
private String resolveSortColumn(String sortKey) {
if (!hasText(sortKey)) {
return "modified";
}
String snake = sortKey.replaceAll("([a-z0-9])([A-Z])", "$1_$2").toLowerCase(Locale.ROOT);
return PAGE_SORT_COLUMNS.contains(snake) ? snake : "modified";
}
private String attachment(String fileName) {
return "attachment" + filenameParameter(fileName);
}
private String filenameParameter(String fileName) {
String encoded = URLEncoder.encode(fileName, StandardCharsets.UTF_8).replace("+", "%20");
return "; filename*=UTF-8''" + encoded;
}
private String fileName(String path) {
if (!hasText(path)) {
return "resource.bin";
}
int index = path.lastIndexOf('/');
return index < 0 ? path : path.substring(index + 1);
}
private Result<BigInteger> approvalResult(ApprovalActionResult result, String approvalMessage, String directMessage) {
return Result.ok(result.isApprovalRequired() ? approvalMessage : directMessage, result.getInstanceId());
}
private java.io.OutputStream output(HttpServletResponse response) {
try {
return response.getOutputStream();
} catch (IOException exception) {
throw new BusinessException(500, 500, "创建 Skill 导出响应失败", exception);
}
}
private boolean hasText(String value) {
return value != null && !value.isBlank();
}
}

View File

@@ -0,0 +1,17 @@
package tech.easyflow.admin.controller.skill.vo;
import java.math.BigInteger;
/**
* Skill 复制请求白名单。
*
* @param sourceId 源 Skill ID
* @param name 新 Skill 标准名称
* @param displayName 新 Skill 展示名称
* @param categoryId 目标分类 ID可为空
*/
public record SkillCopyRequest(BigInteger sourceId,
String name,
String displayName,
BigInteger categoryId) {
}

View File

@@ -0,0 +1,47 @@
package tech.easyflow.admin.controller.skill.vo;
import tech.easyflow.skill.entity.Skill;
import java.math.BigInteger;
/**
* Skill 草稿写入白名单,拒绝客户端覆盖租户、归属人、发布态、快照和 hash 等服务端字段。
*
* @param id Skill ID创建时为空
* @param categoryId 分类 ID
* @param displayName 展示名称
* @param skillContent SKILL.md 内容,仅创建时使用;已有草稿正文通过文件接口原子保存
* @param visibilityScope 可见范围
*/
public record SkillDraftRequest(BigInteger id,
BigInteger categoryId,
String displayName,
String skillContent,
String visibilityScope) {
/**
* 转换为仅包含可写字段的业务实体。
*
* @return Skill 草稿实体
*/
public Skill toEntity() {
Skill skill = new Skill();
skill.setId(id);
skill.setCategoryId(categoryId);
skill.setDisplayName(displayName);
skill.setSkillContent(skillContent);
skill.setVisibilityScope(visibilityScope);
return skill;
}
/**
* 转换为不包含 SKILL.md 正文的基础配置更新实体。
*
* @return Skill 基础配置实体
*/
public Skill toUpdateEntity() {
Skill skill = toEntity();
skill.setSkillContent(null);
return skill;
}
}

View File

@@ -0,0 +1,12 @@
package tech.easyflow.admin.controller.skill.vo;
import java.util.List;
/**
* 选中 Git Skill 候选的标准导入预览准备请求。
*
* @param scanToken 短期扫描令牌
* @param candidateIds 选中的候选 ID
*/
public record SkillGitRepositoryPrepareRequest(String scanToken, List<String> candidateIds) {
}

View File

@@ -0,0 +1,9 @@
package tech.easyflow.admin.controller.skill.vo;
/**
* Git 仓库 Skill 扫描请求。
*
* @param repositoryUrl HTTPS Git 仓库地址,可省略 .git 后缀
*/
public record SkillGitRepositoryScanRequest(String repositoryUrl) {
}

View File

@@ -0,0 +1,41 @@
package tech.easyflow.admin.controller.skill.vo;
import java.util.List;
/**
* 单个标准 Skill ZIP 的独立导入结果。
*
* @param importToken 预检 token
* @param success 是否成功
* @param message 失败原因,成功时为空
* @param skills 导入成功的 Skill
*/
public record SkillImportBatchResultView(
String importToken,
boolean success,
String message,
List<SkillView> skills
) {
/**
* 构造成功结果。
*
* @param importToken 预检 token
* @param skills 导入的 Skill
* @return 成功结果
*/
public static SkillImportBatchResultView succeeded(String importToken, List<SkillView> skills) {
return new SkillImportBatchResultView(importToken, true, null, List.copyOf(skills));
}
/**
* 构造失败结果。
*
* @param importToken 预检 token
* @param message 失败原因
* @return 失败结果
*/
public static SkillImportBatchResultView failed(String importToken, String message) {
return new SkillImportBatchResultView(importToken, false, message, List.of());
}
}

View File

@@ -0,0 +1,21 @@
package tech.easyflow.admin.controller.skill.vo;
import java.math.BigInteger;
/**
* Skill 发布和审批派生状态。
*
* @param id Skill ID
* @param publishStatus 真实发布状态
* @param approvalPending 是否存在进行中审批
* @param currentApprovalActionType 当前审批动作
* @param displayPublishStatus 前端展示状态
* @param currentApprovalInstanceId 当前审批实例 ID
*/
public record SkillPublishStatusView(BigInteger id,
String publishStatus,
Boolean approvalPending,
String currentApprovalActionType,
String displayPublishStatus,
BigInteger currentApprovalInstanceId) {
}

View File

@@ -0,0 +1,112 @@
package tech.easyflow.admin.controller.skill.vo;
import tech.easyflow.skill.entity.SkillToolBinding;
import java.math.BigInteger;
import java.util.List;
/**
* Skill Tool 整组替换请求。
*
* <p>{@code @JsonBody} 当前由 Fastjson 1 完成转换,使用标准 JavaBean 可确保嵌套列表元素
* 按声明类型转换,避免嵌套 record 被保留为 {@code JSONObject}。</p>
*/
public class SkillToolBindingUpdateRequest {
private BigInteger skillId;
private List<Binding> bindings;
/** 创建空请求。 */
public SkillToolBindingUpdateRequest() {
}
/**
* 创建 Skill Tool 绑定请求。
*
* @param skillId Skill ID
* @param bindings 绑定引用
*/
public SkillToolBindingUpdateRequest(BigInteger skillId, List<Binding> bindings) {
this.skillId = skillId;
this.bindings = bindings;
}
/** @return Skill ID */
public BigInteger getSkillId() { return skillId; }
/** @param skillId Skill ID */
public void setSkillId(BigInteger skillId) { this.skillId = skillId; }
/** @return 绑定引用 */
public List<Binding> getBindings() { return bindings; }
/** @param bindings 绑定引用 */
public void setBindings(List<Binding> bindings) { this.bindings = bindings; }
/** 客户端允许提交的最小绑定字段。 */
public static class Binding {
private String toolType;
private BigInteger targetId;
private Boolean hitlEnabled;
private Integer sortNo;
private String mcpToolManifestHash;
/** 创建空绑定。 */
public Binding() {
}
/**
* 创建最小 Tool 绑定。
*
* @param toolType Tool 类型
* @param targetId 目标资源 ID
* @param hitlEnabled 是否调用前确认
* @param sortNo 排序号
* @param mcpToolManifestHash MCP Tool 清单 hash
*/
public Binding(String toolType, BigInteger targetId, Boolean hitlEnabled,
Integer sortNo, String mcpToolManifestHash) {
this.toolType = toolType;
this.targetId = targetId;
this.hitlEnabled = hitlEnabled;
this.sortNo = sortNo;
this.mcpToolManifestHash = mcpToolManifestHash;
}
/** @return Tool 类型 */
public String getToolType() { return toolType; }
/** @param toolType Tool 类型 */
public void setToolType(String toolType) { this.toolType = toolType; }
/** @return 目标资源 ID */
public BigInteger getTargetId() { return targetId; }
/** @param targetId 目标资源 ID */
public void setTargetId(BigInteger targetId) { this.targetId = targetId; }
/** @return 是否调用前确认 */
public Boolean getHitlEnabled() { return hitlEnabled; }
/** @param hitlEnabled 是否调用前确认 */
public void setHitlEnabled(Boolean hitlEnabled) { this.hitlEnabled = hitlEnabled; }
/** @return 排序号 */
public Integer getSortNo() { return sortNo; }
/** @param sortNo 排序号 */
public void setSortNo(Integer sortNo) { this.sortNo = sortNo; }
/** @return MCP Tool 清单 hash */
public String getMcpToolManifestHash() { return mcpToolManifestHash; }
/** @param mcpToolManifestHash MCP Tool 清单 hash */
public void setMcpToolManifestHash(String mcpToolManifestHash) {
this.mcpToolManifestHash = mcpToolManifestHash;
}
/**
* 转换为领域绑定引用。
*
* @return 最小 Tool 绑定
*/
public SkillToolBinding toEntity() {
SkillToolBinding value = new SkillToolBinding();
value.setToolType(toolType);
value.setTargetId(targetId);
value.setHitlEnabled(hitlEnabled);
value.setSortNo(sortNo);
value.setMcpToolManifestHash(mcpToolManifestHash);
return value;
}
}
}

View File

@@ -0,0 +1,152 @@
package tech.easyflow.admin.controller.skill.vo;
import com.easyagents.skill.util.SkillResources;
import tech.easyflow.skill.entity.Skill;
import tech.easyflow.skill.entity.SkillResource;
import tech.easyflow.skill.entity.SkillToolBinding;
import java.math.BigInteger;
import java.util.Date;
import java.util.List;
/**
* 管理端 Skill 安全视图。
*
* @param id Skill ID
* @param categoryId 分类 ID
* @param name 标准名称
* @param displayName 展示名称
* @param description 用途描述
* @param visibilityScope 使用范围
* @param packageHash 标准包哈希
* @param snapshotHash 发布快照哈希
* @param publishStatus 发布状态
* @param currentApprovalInstanceId 当前审批实例 ID
* @param approvalPending 是否审批中
* @param currentApprovalActionType 当前审批动作
* @param displayPublishStatus 展示状态
* @param created 创建时间
* @param modified 修改时间
* @param createdByName 创建人昵称与账号
* @param readable 是否可读
* @param manageable 是否可管理
* @param resources 资源摘要
* @param toolBindings 平台 Tool 草稿绑定摘要
* @param toolCount 实际 Tool 数
* @param hasToolUpdate Tool 草稿是否与线上快照不同
*/
public record SkillView(BigInteger id,
BigInteger categoryId,
String name,
String displayName,
String description,
String visibilityScope,
String packageHash,
String snapshotHash,
String publishStatus,
BigInteger currentApprovalInstanceId,
Boolean approvalPending,
String currentApprovalActionType,
String displayPublishStatus,
Date created,
Date modified,
String createdByName,
boolean readable,
boolean manageable,
List<ResourceView> resources,
List<ToolBindingView> toolBindings,
int toolCount,
boolean hasToolUpdate) {
/**
* 从领域实体构造管理端视图。
*
* @param skill Skill 实体
* @param readable 是否可读
* @param manageable 是否可管理
* @return 管理端视图
*/
public static SkillView from(Skill skill, boolean readable, boolean manageable) {
List<ResourceView> resources = skill.getResources() == null ? null
: skill.getResources().stream().map(ResourceView::from).toList();
List<ToolBindingView> toolBindings = skill.getToolBindings() == null ? null
: skill.getToolBindings().stream().map(ToolBindingView::from).toList();
int toolCount = skill.getToolBindings() == null ? 0 : skill.getToolBindings().stream()
.mapToInt(binding -> "MCP".equalsIgnoreCase(binding.getToolType())
? Math.max(0, binding.getMcpToolCount() == null ? 0 : binding.getMcpToolCount()) : 1)
.sum();
return new SkillView(skill.getId(), skill.getCategoryId(), skill.getName(), skill.getDisplayName(),
skill.getDescription(), skill.getVisibilityScope(), skill.getPackageHash(),
skill.getSnapshotHash(), skill.getPublishStatus(), skill.getCurrentApprovalInstanceId(),
skill.getApprovalPending(), skill.getCurrentApprovalActionType(), skill.getDisplayPublishStatus(),
skill.getCreated(), skill.getModified(), skill.getCreatedByName(), readable, manageable, resources,
toolBindings, toolCount, hasToolUpdate(skill));
}
/**
* Skill 包内资源摘要。
*
* @param id 资源 ID
* @param path 标准相对路径
* @param kind 按路径派生的语义类型
* @param mediaType 媒体类型
* @param isText 是否文本
* @param contentHash 内容哈希
* @param size 字节数
*/
public record ResourceView(BigInteger id, String path, String kind, String mediaType,
Boolean isText, String contentHash, Long size) {
/**
* 转换资源实体。
*
* @param resource 资源实体
* @return 资源摘要
*/
public static ResourceView from(SkillResource resource) {
String path = resource.getNormalizedPath();
return new ResourceView(resource.getId(), path, SkillResources.classify(path).name(),
resource.getMediaType(), resource.getIsText(), resource.getContentHash(), resource.getSize());
}
}
private static boolean hasToolUpdate(Skill skill) {
if (skill.getToolBindings() == null) {
return false;
}
Object published = skill.getPublishedToolBindingsJson() == null
? null : skill.getPublishedToolBindingsJson().get("bindings");
List<String> currentKeys = skill.getToolBindings().stream().map(SkillView::bindingKey).toList();
if (!(published instanceof List<?> list)) {
return !currentKeys.isEmpty();
}
List<String> publishedKeys = list.stream().map(item -> {
if (!(item instanceof java.util.Map<?, ?> map)) {
return "INVALID";
}
return String.valueOf(map.get("toolType")) + ":" + map.get("targetId") + ":"
+ Boolean.TRUE.equals(map.get("hitlEnabled")) + ":" + map.get("mcpToolManifestHash");
}).toList();
return !currentKeys.equals(publishedKeys);
}
private static String bindingKey(SkillToolBinding binding) {
return binding.getToolType() + ":" + binding.getTargetId() + ":"
+ Boolean.TRUE.equals(binding.getHitlEnabled()) + ":" + binding.getMcpToolManifestHash();
}
/**
* Skill 平台 Tool 草稿绑定安全摘要。
*/
public record ToolBindingView(BigInteger id, String toolType, BigInteger targetId,
Boolean hitlEnabled, Integer mcpToolCount,
String mcpToolManifestHash, Integer sortNo,
java.util.Map<String, Object> resourceSummary) {
/** @param binding 绑定实体 @return 安全摘要 */
public static ToolBindingView from(SkillToolBinding binding) {
return new ToolBindingView(binding.getId(), binding.getToolType(), binding.getTargetId(),
binding.getHitlEnabled(), binding.getMcpToolCount(), binding.getMcpToolManifestHash(),
binding.getSortNo(), binding.getResourceSummary());
}
}
}

View File

@@ -10,6 +10,8 @@ import tech.easyflow.common.domain.Result;
import tech.easyflow.common.satoken.util.SaTokenUtil; import tech.easyflow.common.satoken.util.SaTokenUtil;
import tech.easyflow.common.web.exceptions.BusinessException; import tech.easyflow.common.web.exceptions.BusinessException;
import tech.easyflow.common.web.jsonbody.JsonBody; import tech.easyflow.common.web.jsonbody.JsonBody;
import tech.easyflow.admin.model.SystemFormOptionsView;
import tech.easyflow.admin.service.system.SystemFormOptionService;
import tech.easyflow.approval.entity.vo.ApprovalAssigneeOptionVo; import tech.easyflow.approval.entity.vo.ApprovalAssigneeOptionVo;
import tech.easyflow.approval.entity.vo.ApprovalFlowDetailVo; import tech.easyflow.approval.entity.vo.ApprovalFlowDetailVo;
import tech.easyflow.approval.entity.vo.ApprovalFlowPageVo; import tech.easyflow.approval.entity.vo.ApprovalFlowPageVo;
@@ -36,6 +38,20 @@ public class ApprovalFlowController {
@Resource @Resource
private ApprovalAssigneeService approvalAssigneeService; private ApprovalAssigneeService approvalAssigneeService;
@Resource
private SystemFormOptionService systemFormOptionService;
/**
* 查询审批流程配置所需的资源范围选项。
*
* @return 非 Bot 分类和部门树
*/
@GetMapping("/resourceScopeOptions")
@SaCheckPermission("/api/v1/approvalFlow/save")
public Result<SystemFormOptionsView.ApprovalResourceScopeOptions> resourceScopeOptions() {
assertSuperAdmin();
return Result.ok(systemFormOptionService.approvalResourceScopeOptions());
}
/** /**
* 分页查询审批流程。 * 分页查询审批流程。
@@ -102,6 +118,9 @@ public class ApprovalFlowController {
@SaCheckPermission("/api/v1/approvalFlow/save") @SaCheckPermission("/api/v1/approvalFlow/save")
public Result<BigInteger> save(@JsonBody ApprovalFlowDetailVo request) { public Result<BigInteger> save(@JsonBody ApprovalFlowDetailVo request) {
assertSuperAdmin(); assertSuperAdmin();
systemFormOptionService.validateApprovalScopes(
request == null ? null : request.getResourceType(),
request == null ? null : request.getScopes());
BigInteger operatorId = SaTokenUtil.getLoginAccount().getId(); BigInteger operatorId = SaTokenUtil.getLoginAccount().getId();
return Result.ok(approvalFlowService.saveFlow(request, operatorId)); return Result.ok(approvalFlowService.saveFlow(request, operatorId));
} }
@@ -116,6 +135,9 @@ public class ApprovalFlowController {
@SaCheckPermission("/api/v1/approvalFlow/save") @SaCheckPermission("/api/v1/approvalFlow/save")
public Result<Void> update(@JsonBody ApprovalFlowDetailVo request) { public Result<Void> update(@JsonBody ApprovalFlowDetailVo request) {
assertSuperAdmin(); assertSuperAdmin();
systemFormOptionService.validateApprovalScopes(
request == null ? null : request.getResourceType(),
request == null ? null : request.getScopes());
BigInteger operatorId = SaTokenUtil.getLoginAccount().getId(); BigInteger operatorId = SaTokenUtil.getLoginAccount().getId();
approvalFlowService.updateFlow(request, operatorId); approvalFlowService.updateFlow(request, operatorId);
return Result.ok(); return Result.ok();

View File

@@ -3,15 +3,24 @@ package tech.easyflow.admin.controller.system;
import cn.dev33.satoken.annotation.SaCheckPermission; import cn.dev33.satoken.annotation.SaCheckPermission;
import cn.dev33.satoken.stp.StpUtil; import cn.dev33.satoken.stp.StpUtil;
import cn.hutool.crypto.digest.BCrypt; import cn.hutool.crypto.digest.BCrypt;
import com.alibaba.fastjson2.JSONObject;
import com.mybatisflex.core.paginate.Page; import com.mybatisflex.core.paginate.Page;
import com.mybatisflex.core.query.QueryCondition;
import com.mybatisflex.core.query.QueryMethods;
import com.mybatisflex.core.query.QueryWrapper; import com.mybatisflex.core.query.QueryWrapper;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse; import jakarta.servlet.http.HttpServletResponse;
import org.springframework.dao.DuplicateKeyException; import org.springframework.dao.DuplicateKeyException;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile; import org.springframework.web.multipart.MultipartFile;
import tech.easyflow.auth.entity.EncryptedCredentialDTO;
import tech.easyflow.auth.service.AuthCredentialKeyService;
import tech.easyflow.auth.service.AuthService;
import tech.easyflow.common.constant.Constants;
import tech.easyflow.common.constant.enums.EnumAccountType; import tech.easyflow.common.constant.enums.EnumAccountType;
import tech.easyflow.common.constant.enums.EnumDataStatus; import tech.easyflow.common.constant.enums.EnumDataStatus;
import tech.easyflow.common.domain.Result; import tech.easyflow.common.domain.Result;
@@ -21,18 +30,33 @@ import tech.easyflow.common.util.StringUtil;
import tech.easyflow.common.web.controller.BaseCurdController; import tech.easyflow.common.web.controller.BaseCurdController;
import tech.easyflow.common.web.jsonbody.JsonBody; import tech.easyflow.common.web.jsonbody.JsonBody;
import tech.easyflow.log.annotation.LogRecord; import tech.easyflow.log.annotation.LogRecord;
import tech.easyflow.admin.controller.system.vo.SysAccountProfileVo;
import tech.easyflow.admin.model.SystemFormOptionsView;
import tech.easyflow.admin.service.system.SystemFormOptionService;
import tech.easyflow.system.entity.SysAccount; import tech.easyflow.system.entity.SysAccount;
import tech.easyflow.system.entity.SysRole;
import tech.easyflow.system.entity.vo.SysAccountBatchActionResultVo; import tech.easyflow.system.entity.vo.SysAccountBatchActionResultVo;
import tech.easyflow.system.entity.vo.SysAccountImportResultVo; import tech.easyflow.system.entity.vo.SysAccountImportResultVo;
import tech.easyflow.system.service.SysAccountService; import tech.easyflow.system.service.SysAccountService;
import tech.easyflow.system.service.SysRoleService;
import tech.easyflow.system.util.SysPasswordPolicy; import tech.easyflow.system.util.SysPasswordPolicy;
import java.net.URLEncoder; import javax.annotation.Resource;
import java.io.Serializable; import java.io.Serializable;
import java.math.BigInteger; import java.math.BigInteger;
import java.net.URLEncoder;
import java.util.ArrayList;
import java.util.Collection; import java.util.Collection;
import java.util.Date; import java.util.Date;
import java.util.LinkedHashSet;
import java.util.List; import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
import static tech.easyflow.system.entity.table.SysAccountRoleTableDef.SYS_ACCOUNT_ROLE;
import static tech.easyflow.system.entity.table.SysAccountTableDef.SYS_ACCOUNT;
import static tech.easyflow.system.entity.table.SysRoleTableDef.SYS_ROLE;
/** /**
* 用户表 控制层。 * 用户表 控制层。
@@ -43,22 +67,123 @@ import java.util.List;
@RestController("sysAccountController") @RestController("sysAccountController")
@RequestMapping("/api/v1/sysAccount") @RequestMapping("/api/v1/sysAccount")
public class SysAccountController extends BaseCurdController<SysAccountService, SysAccount> { public class SysAccountController extends BaseCurdController<SysAccountService, SysAccount> {
public SysAccountController(SysAccountService service) { private static final String ACCOUNT_SEARCH_KEYWORD_PARAM = "keyword";
private static final String SUPER_ADMIN_HOME_PATH = "/dashboard/workspace";
private static final String USER_HOME_PATH = "/ai/agent-chat";
private final AuthCredentialKeyService credentialKeyService;
private final SysRoleService sysRoleService;
private final SystemFormOptionService systemFormOptionService;
@Resource
private AuthService authService;
/**
* 创建用户管理控制器。
*
* @param service 用户服务
* @param credentialKeyService 凭证密钥服务
* @param sysRoleService 角色服务
* @param systemFormOptionService 用户表单安全选项服务
*/
public SysAccountController(SysAccountService service,
AuthCredentialKeyService credentialKeyService,
SysRoleService sysRoleService,
SystemFormOptionService systemFormOptionService) {
super(service); super(service);
this.credentialKeyService = credentialKeyService;
this.sysRoleService = sysRoleService;
this.systemFormOptionService = systemFormOptionService;
}
/**
* 构造账号列表查询条件。
*
* <p>统一关键字同时匹配账号、昵称、电话、邮件和已关联角色名称。角色条件使用相关
* {@code EXISTS} 子查询,避免多角色关联导致分页记录重复。</p>
*
* @param request 当前 HTTP 请求
* @return 账号列表查询条件
*/
@Override
protected QueryWrapper buildQueryWrapper(HttpServletRequest request) {
String keyword = request.getParameter(ACCOUNT_SEARCH_KEYWORD_PARAM);
if (!StringUtil.hasText(keyword)) {
return super.buildQueryWrapper(request);
}
String likePattern = buildLiteralContainsPattern(normalizeSearchKeyword(keyword));
QueryWrapper roleExistsQuery = QueryMethods.selectOne()
.from(SYS_ACCOUNT_ROLE)
.innerJoin(SYS_ROLE)
.on(SYS_ACCOUNT_ROLE.ROLE_ID.eq(SYS_ROLE.ID))
.where(SYS_ACCOUNT_ROLE.ACCOUNT_ID.eq(SYS_ACCOUNT.ID))
// 关联角色必须与账号属于同一租户,避免异常关系数据跨租户命中。
.and(SYS_ROLE.TENANT_ID.eq(SYS_ACCOUNT.TENANT_ID))
.and(SYS_ROLE.ROLE_NAME.likeRaw(likePattern));
QueryCondition keywordCondition = SYS_ACCOUNT.LOGIN_NAME.likeRaw(likePattern)
.or(SYS_ACCOUNT.NICKNAME.likeRaw(likePattern))
.or(SYS_ACCOUNT.MOBILE.likeRaw(likePattern))
.or(SYS_ACCOUNT.EMAIL.likeRaw(likePattern))
.or(QueryMethods.exists(roleExistsQuery));
return super.buildQueryWrapper(request).and(keywordCondition);
} }
@Override @Override
@LogRecord("分页查询") @LogRecord("分页查询")
protected Page<SysAccount> queryPage(Page<SysAccount> page, QueryWrapper queryWrapper) { protected Page<SysAccount> queryPage(Page<SysAccount> page, QueryWrapper queryWrapper) {
return service.getMapper().paginateWithRelations(page, queryWrapper); Page<SysAccount> result = service.getMapper().paginateWithRelations(page, queryWrapper);
fillRoleNames(result.getRecords());
return result;
}
/**
* 按当前分页内的角色 ID 批量补全角色名称。
*
* @param accounts 当前页账号
*/
private void fillRoleNames(List<SysAccount> accounts) {
if (accounts == null || accounts.isEmpty()) {
return;
}
Set<BigInteger> roleIds = accounts.stream()
.map(SysAccount::getRoleIds)
.filter(java.util.Objects::nonNull)
.flatMap(Collection::stream)
.filter(java.util.Objects::nonNull)
.collect(Collectors.toCollection(LinkedHashSet::new));
if (roleIds.isEmpty()) {
accounts.forEach(account -> account.setRoleNames(List.of()));
return;
}
Map<BigInteger, String> roleNameMap = sysRoleService.listByIds(roleIds).stream()
.filter(role -> role.getId() != null && StringUtil.hasText(role.getRoleName()))
.collect(Collectors.toMap(
SysRole::getId,
SysRole::getRoleName,
(first, ignored) -> first
));
accounts.forEach(account -> {
List<BigInteger> accountRoleIds = account.getRoleIds();
if (accountRoleIds == null || accountRoleIds.isEmpty()) {
account.setRoleNames(List.of());
return;
}
List<String> roleNames = accountRoleIds.stream()
.map(roleNameMap::get)
.filter(StringUtil::hasText)
.distinct()
.collect(Collectors.toList());
account.setRoleNames(roleNames);
});
} }
@Override @Override
protected Result onSaveOrUpdateBefore(SysAccount entity, boolean isSave) { protected Result onSaveOrUpdateBefore(SysAccount entity, boolean isSave) {
systemFormOptionService.validateAccountReferences(entity);
LoginAccount loginUser = SaTokenUtil.getLoginAccount(); LoginAccount loginUser = SaTokenUtil.getLoginAccount();
BigInteger tenantId = loginUser.getTenantId();
if (isSave) { if (isSave) {
commonFiled(entity, loginUser.getId(), tenantId, loginUser.getDeptId());
// 查询用户名是否存在 // 查询用户名是否存在
// long count = Db.selectCount(SqlPrepare.COUNT_ACCOUNT_BY_UNI_KEY, entity.getLoginName(), tenantId); // long count = Db.selectCount(SqlPrepare.COUNT_ACCOUNT_BY_UNI_KEY, entity.getLoginName(), tenantId);
QueryWrapper w = QueryWrapper.create(); QueryWrapper w = QueryWrapper.create();
@@ -67,7 +192,11 @@ public class SysAccountController extends BaseCurdController<SysAccountService,
if (count > 0) { if (count > 0) {
return Result.fail(1, "用户名已存在"); return Result.fail(1, "用户名已存在");
} }
String password = entity.getPassword(); Result<?> roleValidation = validateCreateRoles(entity);
if (roleValidation != null) {
return roleValidation;
}
String password = decryptInitialPassword(entity.getPasswordCredential());
if (!StringUtil.hasText(password)) { if (!StringUtil.hasText(password)) {
return Result.fail(1, "密码不能为空"); return Result.fail(1, "密码不能为空");
} }
@@ -98,6 +227,34 @@ public class SysAccountController extends BaseCurdController<SysAccountService,
return null; return null;
} }
/**
* 查询账号表单所需的部门、角色和岗位选项。
*
* @return 账号表单安全选项
*/
@GetMapping("/formOptions")
@SaCheckPermission("/api/v1/sysAccount/save")
public Result<SystemFormOptionsView.AccountFormOptions> formOptions() {
return Result.ok(systemFormOptionService.accountFormOptions());
}
/**
* 填充账号创建的公共字段。
*
* <p>账号部门由管理端表单指定;只有未提交部门时才沿用通用创建流程的默认部门。</p>
*
* @param entity 待创建的账号
* @param loginAccount 当前登录账号
*/
@Override
protected void fillCreateCommonFields(SysAccount entity, LoginAccount loginAccount) {
BigInteger selectedDeptId = entity.getDeptId();
super.fillCreateCommonFields(entity, loginAccount);
if (selectedDeptId != null) {
entity.setDeptId(selectedDeptId);
}
}
@Override @Override
protected void onSaveOrUpdateAfter(SysAccount entity, boolean isSave) { protected void onSaveOrUpdateAfter(SysAccount entity, boolean isSave) {
service.syncRelations(entity); service.syncRelations(entity);
@@ -118,11 +275,24 @@ public class SysAccountController extends BaseCurdController<SysAccountService,
return super.onRemoveBefore(ids); return super.onRemoveBefore(ids);
} }
/**
* 获取当前账号资料、角色标识与默认首页。
*
* @return 当前账号资料视图
*/
@GetMapping("/myProfile") @GetMapping("/myProfile")
public Result<SysAccount> myProfile() { public Result<SysAccountProfileVo> myProfile() {
LoginAccount account = SaTokenUtil.getLoginAccount(); LoginAccount account = SaTokenUtil.getLoginAccount();
SysAccount sysAccount = service.getById(account.getId()); SysAccount sysAccount = service.getById(account.getId());
return Result.ok(sysAccount); List<String> roles = sysRoleService.getRolesByAccountId(account.getId()).stream()
.map(SysRole::getRoleKey)
.filter(StringUtil::hasText)
.distinct()
.collect(Collectors.toList());
String homePath = roles.contains(Constants.SUPER_ADMIN_ROLE_CODE)
? SUPER_ADMIN_HOME_PATH
: USER_HOME_PATH;
return Result.ok(SysAccountProfileVo.from(sysAccount, roles, homePath));
} }
@PostMapping("/updateProfile") @PostMapping("/updateProfile")
@@ -143,42 +313,59 @@ public class SysAccountController extends BaseCurdController<SysAccountService,
/** /**
* 修改密码,用于修改用户自己的密码 * 修改密码,用于修改用户自己的密码
* *
* @param password 用户的旧密码 * @param encryptedCredential 加密后的当前密码、新密码与确认密码
* @param newPassword 新密码 * @return 密码修改结果
* @param confirmPassword 确认密码
*/ */
@PostMapping("/updatePassword") @PostMapping("/updatePassword")
public Result<Void> updatePassword(@JsonBody(value = "password", required = true) String password, public Result<Void> updatePassword(@JsonBody EncryptedCredentialDTO encryptedCredential) {
@JsonBody(value = "newPassword", required = true) String newPassword, JSONObject payload = credentialKeyService.decryptPayload(encryptedCredential);
@JsonBody(value = "confirmPassword", required = true) String confirmPassword) { String password = payload.getString("password");
String newPassword = payload.getString("newPassword");
String confirmPassword = payload.getString("confirmPassword");
BigInteger loginAccountId = SaTokenUtil.getLoginAccount().getId(); BigInteger loginAccountId = SaTokenUtil.getLoginAccount().getId();
SysAccount record = service.getById(loginAccountId); authService.updateOwnPassword(
if (record == null) { loginAccountId,
return Result.fail("修改失败"); password,
} newPassword,
String pwdDb = record.getPassword(); confirmPassword,
if (!BCrypt.checkpw(password, pwdDb)) { StpUtil.getLoginDevice()
return Result.fail(1, "密码不正确"); );
}
if (!newPassword.equals(confirmPassword)) {
return Result.fail(2, "两次密码不一致");
}
SysPasswordPolicy.validateStrongPassword(newPassword);
SysAccount update = new SysAccount();
update.setId(loginAccountId);
update.setPassword(BCrypt.hashpw(newPassword));
update.setPasswordResetRequired(false);
update.setModified(new Date());
update.setModifiedBy(loginAccountId);
service.updateById(update);
return Result.ok(); return Result.ok();
} }
private String decryptInitialPassword(Map<String, Object> passwordCredential) {
if (passwordCredential == null || passwordCredential.isEmpty()) {
return null;
}
JSONObject payload = credentialKeyService.decryptPayload(toEncryptedCredential(passwordCredential));
return payload.getString("password");
}
private EncryptedCredentialDTO toEncryptedCredential(Map<String, Object> passwordCredential) {
EncryptedCredentialDTO encryptedCredential = new EncryptedCredentialDTO();
encryptedCredential.setKeyId(asString(passwordCredential.get("keyId")));
encryptedCredential.setEncryptedKey(asString(passwordCredential.get("encryptedKey")));
encryptedCredential.setIv(asString(passwordCredential.get("iv")));
encryptedCredential.setCiphertext(asString(passwordCredential.get("ciphertext")));
encryptedCredential.setNonce(asString(passwordCredential.get("nonce")));
return encryptedCredential;
}
private String asString(Object value) {
return value == null ? null : String.valueOf(value);
}
/**
* 将指定账号密码重置为系统默认强密码。
*
* @param id 账号 ID
* @return 本次重置后使用的明文密码
*/
@PostMapping("/resetPassword") @PostMapping("/resetPassword")
@SaCheckPermission("/api/v1/sysAccount/save") @SaCheckPermission("/api/v1/sysAccount/save")
public Result<Void> resetPassword(@JsonBody(value = "id", required = true) BigInteger id) { public Result<String> resetPassword(@JsonBody(value = "id", required = true) BigInteger id) {
service.resetPassword(id, SaTokenUtil.getLoginAccount().getId()); String password = service.resetPassword(id, SaTokenUtil.getLoginAccount().getId());
return Result.ok(); return Result.ok(password);
} }
@PostMapping("/removeBatchWithResult") @PostMapping("/removeBatchWithResult")
@@ -219,8 +406,12 @@ public class SysAccountController extends BaseCurdController<SysAccountService,
service.writeImportTemplate(response.getOutputStream()); service.writeImportTemplate(response.getOutputStream());
} }
/**
* {@inheritDoc}
*/
@Override @Override
@PostMapping("save") @PostMapping("save")
@Transactional(rollbackFor = Exception.class)
public Result<?> save(@JsonBody SysAccount entity) { public Result<?> save(@JsonBody SysAccount entity) {
try { try {
return super.save(entity); return super.save(entity);
@@ -228,4 +419,33 @@ public class SysAccountController extends BaseCurdController<SysAccountService,
return Result.fail(1, "用户名已存在"); return Result.fail(1, "用户名已存在");
} }
} }
/**
* 校验创建账号时提交的角色,并归一化角色 ID。
*
* @param entity 待创建账号
* @return 校验失败结果,校验通过返回 null
*/
private Result<?> validateCreateRoles(SysAccount entity) {
List<BigInteger> roleIds = entity == null ? null : entity.getRoleIds();
Set<BigInteger> uniqueRoleIds = new LinkedHashSet<>();
if (roleIds != null) {
roleIds.stream()
.filter(java.util.Objects::nonNull)
.forEach(uniqueRoleIds::add);
}
if (uniqueRoleIds.isEmpty()) {
return Result.fail(1, "角色不能为空");
}
List<SysRole> roles = sysRoleService.listByIds(uniqueRoleIds);
boolean valid = roles.size() == uniqueRoleIds.size()
&& roles.stream().allMatch(role ->
EnumDataStatus.AVAILABLE.getCode().equals(role.getStatus()));
if (!valid) {
return Result.fail(1, "角色不存在或已禁用");
}
entity.setRoleIds(new ArrayList<>(uniqueRoleIds));
return null;
}
} }

View File

@@ -6,18 +6,21 @@ import com.mybatisflex.core.query.QueryWrapper;
import com.mybatisflex.core.table.TableInfo; import com.mybatisflex.core.table.TableInfo;
import com.mybatisflex.core.table.TableInfoFactory; import com.mybatisflex.core.table.TableInfoFactory;
import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletRequest;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
import tech.easyflow.ai.service.KnowledgeSharePermissionService; import tech.easyflow.ai.service.KnowledgeSharePermissionService;
import tech.easyflow.ai.service.WorkflowApiPermissionService; import tech.easyflow.ai.service.WorkflowApiPermissionService;
import tech.easyflow.ai.enums.KnowledgeApiPermissionScope;
import tech.easyflow.common.domain.Result; import tech.easyflow.common.domain.Result;
import tech.easyflow.common.entity.LoginAccount; import tech.easyflow.common.entity.LoginAccount;
import tech.easyflow.common.satoken.util.SaTokenUtil; import tech.easyflow.common.satoken.util.SaTokenUtil;
import tech.easyflow.common.util.IdUtil; import tech.easyflow.common.util.IdUtil;
import tech.easyflow.common.vo.PkVo; import tech.easyflow.common.vo.PkVo;
import tech.easyflow.common.web.controller.BaseCurdController; import tech.easyflow.common.web.controller.BaseCurdController;
import tech.easyflow.common.web.jsonbody.JsonBody;
import tech.easyflow.system.entity.SysApiKey; import tech.easyflow.system.entity.SysApiKey;
import tech.easyflow.system.entity.SysApiKeyResourceMapping; import tech.easyflow.system.entity.SysApiKeyResourceMapping;
import tech.easyflow.system.service.SysApiKeyResourceMappingService; import tech.easyflow.system.service.SysApiKeyResourceMappingService;
@@ -29,6 +32,7 @@ import java.time.LocalDate;
import java.time.ZoneId; import java.time.ZoneId;
import java.util.Date; import java.util.Date;
import java.util.List; import java.util.List;
import java.util.Set;
/** /**
* 控制层。 * 控制层。
@@ -39,10 +43,31 @@ import java.util.List;
@RestController @RestController
@RequestMapping("/api/v1/sysApiKey") @RequestMapping("/api/v1/sysApiKey")
public class SysApiKeyController extends BaseCurdController<SysApiKeyService, SysApiKey> { public class SysApiKeyController extends BaseCurdController<SysApiKeyService, SysApiKey> {
/**
* 访问令牌名称最大长度。
*/
private static final int API_KEY_NAME_MAX_LENGTH = 100;
/**
* 兼容旧客户端时使用的默认名称前缀。
*/
private static final String DEFAULT_API_KEY_NAME_PREFIX = "访问令牌-";
public SysApiKeyController(SysApiKeyService service) { public SysApiKeyController(SysApiKeyService service) {
super(service); super(service);
} }
/**
* 获取 API Key 列表关键字搜索字段。
*
* @return 名称和 Key 属性
*/
@Override
protected String[] getKeywordSearchProperties() {
return new String[]{"name", "apiKey"};
}
@Resource @Resource
private SysApiKeyResourceMappingService sysApiKeyResourceMappingService; private SysApiKeyResourceMappingService sysApiKeyResourceMappingService;
@Resource @Resource
@@ -52,13 +77,19 @@ public class SysApiKeyController extends BaseCurdController<SysApiKeyService, Sy
/** /**
* 添加(保存)数据 * 添加(保存)数据
* *
* @param name 访问令牌名称
* @return {@code Result.errorCode == 0} 添加成功,否则添加失败 * @return {@code Result.errorCode == 0} 添加成功,否则添加失败
*/ */
@PostMapping("/key/save") @PostMapping("/key/save")
@SaCheckPermission("/api/v1/sysApiKey/save") @SaCheckPermission("/api/v1/sysApiKey/save")
public Result<PkVo> save() { public Result<PkVo> save(@JsonBody(value = "name", required = false) String name) {
String apiKey = IdUtil.generateUUID(); String apiKey = IdUtil.generateUUID();
String normalizedName = normalizeCreateName(name, apiKey);
if (normalizedName.length() > API_KEY_NAME_MAX_LENGTH) {
return Result.fail("访问令牌名称不能超过100个字符", null);
}
SysApiKey entity = new SysApiKey(); SysApiKey entity = new SysApiKey();
entity.setName(normalizedName);
entity.setApiKey(apiKey); entity.setApiKey(apiKey);
entity.setCreated(new Date()); entity.setCreated(new Date());
entity.setStatus(1); entity.setStatus(1);
@@ -83,12 +114,61 @@ public class SysApiKeyController extends BaseCurdController<SysApiKeyService, Sy
return Result.ok(new PkVo(pkArgs)); return Result.ok(new PkVo(pkArgs));
} }
/**
* 更新访问令牌基础信息与授权。
*
* <p>权限开关不映射数据库列,权限更新请求可能只包含主键与权限字段。
* 此时跳过主表更新,避免 MyBatis-Flex 生成空的 {@code SET} 子句。</p>
*
* @param entity 待更新的访问令牌
* @return 更新结果
*/
@Override
@PostMapping("/update")
@Transactional(rollbackFor = Exception.class)
public Result<?> update(@JsonBody SysApiKey entity) {
if (entity == null || entity.getId() == null) {
return Result.fail("访问令牌 ID 不能为空");
}
Result<?> nameValidationResult = normalizeAndValidateUpdateName(entity);
if (nameValidationResult != null) {
return nameValidationResult;
}
if (!hasPersistentUpdateFields(entity) && !hasPermissionUpdateFields(entity)) {
return Result.fail("没有可更新的访问令牌字段");
}
if (hasNewKnowledgePermissionFields(entity)
&& !hasCompleteKnowledgePermissionFields(entity)) {
return Result.fail("知识库读取、导入、维护权限必须同时提交");
}
if (service.getById(entity.getId()) == null) {
return Result.fail("访问令牌不存在");
}
Result<?> beforeResult = onSaveOrUpdateBefore(entity, false);
if (beforeResult != null) {
return beforeResult;
}
if (hasPersistentUpdateFields(entity)) {
service.updateById(entity);
}
onSaveOrUpdateAfter(entity, false);
return Result.ok();
}
@Override @Override
protected void onSaveOrUpdateAfter(SysApiKey entity, boolean isSave) { protected void onSaveOrUpdateAfter(SysApiKey entity, boolean isSave) {
if (entity.getPermissionIds() != null) { if (entity.getPermissionIds() != null) {
sysApiKeyResourceMappingService.authInterface(entity); sysApiKeyResourceMappingService.authInterface(entity);
} }
if (entity.getKnowledgeShareEnabled() != null) { if (hasNewKnowledgePermissionFields(entity)) {
knowledgeSharePermissionService.replaceApiPermissions(
entity.getId(),
Boolean.TRUE.equals(entity.getKnowledgeReadEnabled()),
Boolean.TRUE.equals(entity.getKnowledgeImportEnabled()),
Boolean.TRUE.equals(entity.getKnowledgeMaintenanceEnabled())
);
} else if (entity.getKnowledgeShareEnabled() != null) {
// 兼容旧客户端:开启旧总开关只授予读取和导入,维护权限保持关闭。
knowledgeSharePermissionService.replaceApiShareEnabled(entity.getId(), entity.getKnowledgeShareEnabled()); knowledgeSharePermissionService.replaceApiShareEnabled(entity.getId(), entity.getKnowledgeShareEnabled());
} }
if (entity.getWorkflowApiEnabled() != null) { if (entity.getWorkflowApiEnabled() != null) {
@@ -130,11 +210,18 @@ public class SysApiKeyController extends BaseCurdController<SysApiKeyService, Sy
List<BigInteger> resourceIds = sysApiKeyResourceMappingService.listAs(interfaceWrapper, BigInteger.class); List<BigInteger> resourceIds = sysApiKeyResourceMappingService.listAs(interfaceWrapper, BigInteger.class);
entity.setPermissionIds(resourceIds); entity.setPermissionIds(resourceIds);
QueryWrapper knowledgeWrapper = QueryWrapper.create() Set<String> knowledgeScopes =
.select(SysApiKeyResourceMapping::getId) knowledgeSharePermissionService.getApiPermissionScopes(entity.getId());
.eq(SysApiKeyResourceMapping::getApiKeyId, entity.getId()) boolean readEnabled =
.eq(SysApiKeyResourceMapping::getResourceType, "KNOWLEDGE"); knowledgeScopes.contains(KnowledgeApiPermissionScope.KNOWLEDGE_READ.name());
entity.setKnowledgeShareEnabled(sysApiKeyResourceMappingService.count(knowledgeWrapper) > 0); boolean importEnabled =
knowledgeScopes.contains(KnowledgeApiPermissionScope.KNOWLEDGE_IMPORT.name());
boolean maintenanceEnabled =
knowledgeScopes.contains(KnowledgeApiPermissionScope.KNOWLEDGE_MAINTENANCE.name());
entity.setKnowledgeReadEnabled(readEnabled);
entity.setKnowledgeImportEnabled(importEnabled);
entity.setKnowledgeMaintenanceEnabled(maintenanceEnabled);
entity.setKnowledgeShareEnabled(readEnabled || importEnabled || maintenanceEnabled);
QueryWrapper workflowWrapper = QueryWrapper.create() QueryWrapper workflowWrapper = QueryWrapper.create()
.select(SysApiKeyResourceMapping::getId) .select(SysApiKeyResourceMapping::getId)
@@ -142,4 +229,96 @@ public class SysApiKeyController extends BaseCurdController<SysApiKeyService, Sy
.eq(SysApiKeyResourceMapping::getResourceType, WorkflowApiPermissionService.RESOURCE_TYPE_WORKFLOW); .eq(SysApiKeyResourceMapping::getResourceType, WorkflowApiPermissionService.RESOURCE_TYPE_WORKFLOW);
entity.setWorkflowApiEnabled(sysApiKeyResourceMappingService.count(workflowWrapper) > 0); entity.setWorkflowApiEnabled(sysApiKeyResourceMappingService.count(workflowWrapper) > 0);
} }
/**
* 判断请求是否提交了任一新版知识库权限字段。
*
* @param entity 访问令牌
* @return 是否提交新版字段
*/
private boolean hasNewKnowledgePermissionFields(SysApiKey entity) {
return entity.getKnowledgeReadEnabled() != null
|| entity.getKnowledgeImportEnabled() != null
|| entity.getKnowledgeMaintenanceEnabled() != null;
}
/**
* 判断请求是否完整提交三个新版知识库权限字段。
*
* @param entity 访问令牌
* @return 三个字段是否均已提交
*/
private boolean hasCompleteKnowledgePermissionFields(SysApiKey entity) {
return entity.getKnowledgeReadEnabled() != null
&& entity.getKnowledgeImportEnabled() != null
&& entity.getKnowledgeMaintenanceEnabled() != null;
}
/**
* 判断请求是否包含主表可持久化字段。
*
* @param entity 访问令牌
* @return 是否需要更新访问令牌主表
*/
private boolean hasPersistentUpdateFields(SysApiKey entity) {
return entity.getName() != null
|| entity.getApiKey() != null
|| entity.getCreated() != null
|| entity.getStatus() != null
|| entity.getDeptId() != null
|| entity.getTenantId() != null
|| entity.getExpiredAt() != null
|| entity.getCreatedBy() != null;
}
/**
* 判断请求是否包含任一非主表权限字段。
*
* @param entity 访问令牌
* @return 是否需要更新权限映射
*/
private boolean hasPermissionUpdateFields(SysApiKey entity) {
return entity.getPermissionIds() != null
|| entity.getKnowledgeShareEnabled() != null
|| hasNewKnowledgePermissionFields(entity)
|| entity.getWorkflowApiEnabled() != null;
}
/**
* 标准化新建访问令牌的名称。
*
* <p>未传名称时生成可识别的兼容名称,避免旧客户端在升级期间创建空名称记录。</p>
*
* @param name 客户端提交的名称
* @param apiKey 新生成的访问令牌
* @return 标准化后的名称
*/
private String normalizeCreateName(String name, String apiKey) {
if (name != null && !name.trim().isEmpty()) {
return name.trim();
}
int suffixStart = Math.max(0, apiKey.length() - 6);
return DEFAULT_API_KEY_NAME_PREFIX + apiKey.substring(suffixStart);
}
/**
* 标准化并校验更新请求中的访问令牌名称。
*
* @param entity 待更新的访问令牌
* @return 校验失败结果;无需校验或校验成功时返回 {@code null}
*/
private Result<?> normalizeAndValidateUpdateName(SysApiKey entity) {
if (entity.getName() == null) {
return null;
}
String normalizedName = entity.getName().trim();
if (normalizedName.isEmpty()) {
return Result.fail("访问令牌名称不能为空");
}
if (normalizedName.length() > API_KEY_NAME_MAX_LENGTH) {
return Result.fail("访问令牌名称不能超过100个字符");
}
entity.setName(normalizedName);
return null;
}
} }

View File

@@ -26,6 +26,16 @@ public class SysApiKeyResourceController extends BaseCurdController<SysApiKeyRes
super(service); super(service);
} }
/**
* 获取接口授权资源关键字搜索字段。
*
* @return 请求接口和标题属性
*/
@Override
protected String[] getKeywordSearchProperties() {
return new String[]{"requestInterface", "title"};
}
/** /**
* 查询普通 API Key 接口授权资源。 * 查询普通 API Key 接口授权资源。
* *

View File

@@ -1,27 +1,39 @@
package tech.easyflow.admin.controller.system; package tech.easyflow.admin.controller.system;
import cn.dev33.satoken.annotation.SaCheckPermission;
import com.mybatisflex.core.query.QueryColumn;
import com.mybatisflex.core.query.QueryWrapper;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import tech.easyflow.approval.entity.ApprovalFlowScope;
import tech.easyflow.approval.entity.ApprovalFlowStepAssignee;
import tech.easyflow.approval.enums.ApprovalAssigneeType;
import tech.easyflow.approval.enums.ApprovalScopeType;
import tech.easyflow.approval.mapper.ApprovalFlowScopeMapper;
import tech.easyflow.approval.mapper.ApprovalFlowStepAssigneeMapper;
import tech.easyflow.common.constant.Constants; import tech.easyflow.common.constant.Constants;
import tech.easyflow.common.constant.enums.EnumDataStatus;
import tech.easyflow.common.domain.Result; import tech.easyflow.common.domain.Result;
import tech.easyflow.common.entity.LoginAccount; import tech.easyflow.common.entity.LoginAccount;
import tech.easyflow.common.satoken.util.SaTokenUtil;
import tech.easyflow.common.tree.Tree; import tech.easyflow.common.tree.Tree;
import tech.easyflow.common.web.controller.BaseCurdController; import tech.easyflow.common.web.controller.BaseCurdController;
import tech.easyflow.common.web.jsonbody.JsonBody;
import tech.easyflow.system.entity.SysAccount; import tech.easyflow.system.entity.SysAccount;
import tech.easyflow.system.entity.SysDept; import tech.easyflow.system.entity.SysDept;
import tech.easyflow.system.service.SysAccountService; import tech.easyflow.system.service.SysAccountService;
import tech.easyflow.system.service.SysDeptService; import tech.easyflow.system.service.SysDeptService;
import tech.easyflow.common.satoken.util.SaTokenUtil;
import com.mybatisflex.core.query.QueryWrapper;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import javax.annotation.Resource;
import java.io.Serializable; import java.io.Serializable;
import java.math.BigInteger; import java.math.BigInteger;
import java.util.Collection; import java.util.Collection;
import java.util.Date; import java.util.Date;
import java.util.LinkedHashSet;
import java.util.List; import java.util.List;
import java.util.Set;
/** /**
* 部门表 控制层。 * 部门表 控制层。
@@ -33,30 +45,154 @@ import java.util.List;
@RequestMapping("/api/v1/sysDept") @RequestMapping("/api/v1/sysDept")
public class SysDeptController extends BaseCurdController<SysDeptService, SysDept> { public class SysDeptController extends BaseCurdController<SysDeptService, SysDept> {
@Resource private final SysAccountService sysAccountService;
private SysAccountService sysAccountService; private final ApprovalFlowStepAssigneeMapper approvalFlowStepAssigneeMapper;
private final ApprovalFlowScopeMapper approvalFlowScopeMapper;
public SysDeptController(SysDeptService service) { /**
* 创建部门管理控制器。
*
* @param service 部门服务
* @param sysAccountService 用户服务
* @param approvalFlowStepAssigneeMapper 审批步骤对象 Mapper
* @param approvalFlowScopeMapper 审批范围 Mapper
*/
public SysDeptController(SysDeptService service,
SysAccountService sysAccountService,
ApprovalFlowStepAssigneeMapper approvalFlowStepAssigneeMapper,
ApprovalFlowScopeMapper approvalFlowScopeMapper) {
super(service); super(service);
this.sysAccountService = sysAccountService;
this.approvalFlowStepAssigneeMapper = approvalFlowStepAssigneeMapper;
this.approvalFlowScopeMapper = approvalFlowScopeMapper;
} }
/**
* 获取部门列表默认排序规则。
*
* @return 默认排序表达式
*/
@Override @Override
protected String getDefaultOrderBy() { protected String getDefaultOrderBy() {
return "sort_no asc"; return "sort_no asc";
} }
/**
* 查询部门列表并组装为树形结构。
*
* @param entity 查询条件
* @param asTree 是否返回树形结构
* @param sortKey 排序字段
* @param sortType 排序方向
* @return 部门树
*/
@Override @Override
@GetMapping("list") @GetMapping("list")
public Result<List<SysDept>> list(SysDept entity, Boolean asTree, String sortKey, String sortType) { public Result<List<SysDept>> list(SysDept entity, Boolean asTree, String sortKey, String sortType) {
QueryWrapper queryWrapper = QueryWrapper.create(entity, buildOperators(entity)); QueryWrapper queryWrapper = QueryWrapper.create(entity, buildOperators(entity));
String keyword = entity == null ? "" : normalizeSearchKeyword(entity.getKeyword());
if (tech.easyflow.common.util.StringUtil.hasText(keyword)) {
queryWrapper.and(buildLiteralContainsCondition(
keyword, new QueryColumn("dept_name"), new QueryColumn("dept_code")));
}
queryWrapper.orderBy(buildOrderBy(sortKey, sortType, getDefaultOrderBy())); queryWrapper.orderBy(buildOrderBy(sortKey, sortType, getDefaultOrderBy()));
List<SysDept> sysMenus = service.list(queryWrapper); List<SysDept> matchedDepartments = service.list(queryWrapper);
return Result.ok(Tree.tryToTree(sysMenus, "id", "parentId")); if (!tech.easyflow.common.util.StringUtil.hasText(keyword)) {
return Result.ok(Tree.tryToTree(matchedDepartments, "id", "parentId"));
}
if (matchedDepartments.isEmpty()) {
return Result.ok(List.of());
} }
// 搜索结果保留所有重名命中项,并补齐各自祖先节点以维持可定位的树结构。
Set<BigInteger> visibleIds = new LinkedHashSet<>();
for (SysDept department : matchedDepartments) {
visibleIds.add(department.getId());
addAncestorIds(visibleIds, department.getAncestors());
}
QueryWrapper visibleDepartmentQuery = QueryWrapper.create()
.in(SysDept::getId, visibleIds)
.orderBy(buildOrderBy(sortKey, sortType, getDefaultOrderBy()));
List<SysDept> visibleDepartments = service.list(visibleDepartmentQuery);
return Result.ok(Tree.tryToTree(visibleDepartments, "id", "parentId"));
}
/**
* 将逗号分隔的祖先 ID 加入可见集合。
*
* @param visibleIds 可见部门 ID 集合
* @param ancestors 祖先路径
*/
private void addAncestorIds(Set<BigInteger> visibleIds, String ancestors) {
if (!tech.easyflow.common.util.StringUtil.hasText(ancestors)) {
return;
}
for (String ancestor : ancestors.split(",")) {
String normalized = ancestor.trim();
if (!normalized.isEmpty() && !"0".equals(normalized)) {
visibleIds.add(new BigInteger(normalized));
}
}
}
/**
* 批量修改部门状态。
*
* @param ids 部门主键集合
* @param status 目标状态
* @return 实际更新数量
*/
@PostMapping("changeStatusBatch")
@SaCheckPermission("/api/v1/sysDept/save")
@Transactional(rollbackFor = Exception.class)
public Result<Integer> changeStatusBatch(
@JsonBody(value = "ids", required = true) List<BigInteger> ids,
@JsonBody(value = "status", required = true) Integer status) {
Set<BigInteger> uniqueIds = normalizeIds(ids);
if (uniqueIds.isEmpty()) {
return Result.fail("请选择需要操作的部门", null);
}
if (!EnumDataStatus.AVAILABLE.getCode().equals(status)
&& !EnumDataStatus.UNAVAILABLE.getCode().equals(status)) {
return Result.fail("部门状态不合法", null);
}
List<SysDept> records = service.listByIds(uniqueIds);
if (records.size() != uniqueIds.size()) {
return Result.fail("部分部门不存在或已删除,请刷新后重试", null);
}
if (EnumDataStatus.UNAVAILABLE.getCode().equals(status) && containsRootDept(records)) {
return Result.fail("根部门不能禁用", null);
}
if (EnumDataStatus.UNAVAILABLE.getCode().equals(status)
&& isUsedByApprovalFlow(uniqueIds)) {
return Result.fail("所选部门已被审批流程使用,不能禁用", null);
}
LoginAccount loginUser = SaTokenUtil.getLoginAccount();
SysDept update = new SysDept();
update.setStatus(status);
update.setModified(new Date());
update.setModifiedBy(loginUser.getId());
QueryWrapper updateWrapper = QueryWrapper.create();
updateWrapper.in(SysDept::getId, uniqueIds);
int updated = service.getMapper().updateByQuery(update, updateWrapper);
if (updated <= 0) {
return Result.fail("部门状态修改失败", null);
}
return Result.ok(updated);
}
/**
* {@inheritDoc}
*/
@Override @Override
protected Result onSaveOrUpdateBefore(SysDept entity, boolean isSave) { protected Result onSaveOrUpdateBefore(SysDept entity, boolean isSave) {
LoginAccount loginUser = SaTokenUtil.getLoginAccount(); LoginAccount loginUser = SaTokenUtil.getLoginAccount();
if (isSave && entity.getStatus() == null) {
entity.setStatus(EnumDataStatus.AVAILABLE.getCode());
}
BigInteger parentId = entity.getParentId(); BigInteger parentId = entity.getParentId();
if (parentId.equals(BigInteger.ZERO)) { if (parentId.equals(BigInteger.ZERO)) {
entity.setAncestors(parentId.toString()); entity.setAncestors(parentId.toString());
@@ -65,7 +201,7 @@ public class SysDeptController extends BaseCurdController<SysDeptService, SysDep
entity.setAncestors(parent.getAncestors() + "," + parentId); entity.setAncestors(parent.getAncestors() + "," + parentId);
} }
if (isSave) { if (isSave) {
commonFiled(entity,loginUser.getId(),loginUser.getTenantId(), loginUser.getDeptId()); commonFiled(entity, loginUser.getId(), loginUser.getTenantId(), loginUser.getDeptId());
} else { } else {
entity.setModified(new Date()); entity.setModified(new Date());
entity.setModifiedBy(loginUser.getId()); entity.setModifiedBy(loginUser.getId());
@@ -73,20 +209,87 @@ public class SysDeptController extends BaseCurdController<SysDeptService, SysDep
return null; return null;
} }
/**
* {@inheritDoc}
*/
@Override @Override
protected Result onRemoveBefore(Collection<Serializable> ids) { protected Result onRemoveBefore(Collection<Serializable> ids) {
List<SysDept> records = service.listByIds(ids); List<SysDept> records = service.listByIds(ids);
for (SysDept dept : records) { if (records.size() != ids.size()) {
if (Constants.ROOT_DEPT.equals(dept.getDeptCode())) { return Result.fail(1, "部分部门不存在或已删除,请刷新后重试");
}
if (containsRootDept(records)) {
return Result.fail(1, "无法删除根部门"); return Result.fail(1, "无法删除根部门");
} }
QueryWrapper childQuery = QueryWrapper.create();
childQuery.in(SysDept::getParentId, ids);
childQuery.notIn(SysDept::getId, ids);
if (service.count(childQuery) > 0) {
return Result.fail(1, "所选部门包含未选中的下级部门,不能删除");
} }
QueryWrapper w = QueryWrapper.create(); if (isUsedByApprovalFlow(ids)) {
w.in(SysAccount::getDeptId, ids); return Result.fail(1, "所选部门已被审批流程使用,请先调整审批配置");
long count = sysAccountService.count(w); }
QueryWrapper accountQuery = QueryWrapper.create();
accountQuery.in(SysAccount::getDeptId, ids);
long count = sysAccountService.count(accountQuery);
if (count > 0) { if (count > 0) {
return Result.fail(1, "部门下有员工,不能删除"); return Result.fail(1, "所选部门下有员工,不能删除");
} }
return super.onRemoveBefore(ids); return super.onRemoveBefore(ids);
} }
/**
* 去重并过滤无效部门主键。
*
* @param ids 原始部门主键集合
* @return 有效且去重后的主键集合
*/
private Set<BigInteger> normalizeIds(Collection<BigInteger> ids) {
Set<BigInteger> uniqueIds = new LinkedHashSet<>();
if (ids == null) {
return uniqueIds;
}
for (BigInteger id : ids) {
if (id != null) {
uniqueIds.add(id);
}
}
return uniqueIds;
}
/**
* 判断部门集合中是否包含根部门。
*
* @param records 部门集合
* @return 包含根部门时返回 {@code true}
*/
private boolean containsRootDept(Collection<SysDept> records) {
return records.stream()
.anyMatch(dept -> Constants.ROOT_DEPT.equals(dept.getDeptCode()));
}
/**
* 判断部门是否仍被审批步骤或审批范围引用。
*
* @param ids 部门主键集合
* @return 存在审批流程引用时返回 {@code true}
*/
private boolean isUsedByApprovalFlow(Collection<? extends Serializable> ids) {
QueryWrapper assigneeQuery = QueryWrapper.create();
assigneeQuery.eq(
ApprovalFlowStepAssignee::getAssigneeType,
ApprovalAssigneeType.DEPT.getCode());
assigneeQuery.in(ApprovalFlowStepAssignee::getTargetId, ids);
if (approvalFlowStepAssigneeMapper.selectCountByQuery(assigneeQuery) > 0) {
return true;
}
QueryWrapper scopeQuery = QueryWrapper.create();
scopeQuery.eq(ApprovalFlowScope::getScopeType, ApprovalScopeType.DEPT.getCode());
scopeQuery.in(ApprovalFlowScope::getScopeValue, ids);
return approvalFlowScopeMapper.selectCountByQuery(scopeQuery) > 0;
}
} }

View File

@@ -1,16 +1,31 @@
package tech.easyflow.admin.controller.system; package tech.easyflow.admin.controller.system;
import com.mybatisflex.core.paginate.Page;
import com.mybatisflex.core.query.QueryCondition;
import com.mybatisflex.core.query.QueryMethods;
import com.mybatisflex.core.query.QueryWrapper;
import com.mybatisflex.core.relation.RelationManager;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import tech.easyflow.common.util.StringUtil;
import tech.easyflow.common.entity.LoginAccount;
import tech.easyflow.common.satoken.util.SaTokenUtil;
import tech.easyflow.common.web.controller.BaseCurdController; import tech.easyflow.common.web.controller.BaseCurdController;
import tech.easyflow.common.web.exceptions.BusinessException;
import tech.easyflow.log.annotation.LogRecord; import tech.easyflow.log.annotation.LogRecord;
import tech.easyflow.system.entity.SysLog; import tech.easyflow.system.entity.SysLog;
import tech.easyflow.system.service.SysLogService; import tech.easyflow.system.service.SysLogService;
import com.mybatisflex.core.paginate.Page;
import com.mybatisflex.core.query.QueryWrapper;
import com.mybatisflex.core.relation.RelationManager;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeParseException;
import java.util.Collections; import java.util.Collections;
import java.util.Date;
import static tech.easyflow.system.entity.table.SysAccountTableDef.SYS_ACCOUNT;
import static tech.easyflow.system.entity.table.SysLogTableDef.SYS_LOG;
/** /**
* 操作日志表 控制层。 * 操作日志表 控制层。
@@ -21,14 +36,102 @@ import java.util.Collections;
@RestController @RestController
@RequestMapping("/api/v1/sysLog") @RequestMapping("/api/v1/sysLog")
public class SysLogController extends BaseCurdController<SysLogService, SysLog> { public class SysLogController extends BaseCurdController<SysLogService, SysLog> {
private static final long MAX_PAGE_SIZE = 100L;
private static final DateTimeFormatter QUERY_TIME_FORMATTER =
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
/**
* 创建操作日志控制器。
*
* @param service 操作日志服务
*/
public SysLogController(SysLogService service) { public SysLogController(SysLogService service) {
super(service); super(service);
} }
/**
* 构造通用查询条件,并追加操作时间范围。
*
* @param request 当前 HTTP 请求
* @return 操作日志查询条件
* @throws BusinessException 时间格式不正确或开始时间晚于结束时间时抛出
*/
@Override
protected QueryWrapper buildQueryWrapper(HttpServletRequest request) {
QueryWrapper queryWrapper = super.buildQueryWrapper(request);
String keyword = normalizeSearchKeyword(request.getParameter("keyword"));
if (StringUtil.hasText(keyword)) {
LoginAccount account = SaTokenUtil.getLoginAccount();
String pattern = buildLiteralContainsPattern(keyword);
QueryCondition accountKeyword = SYS_ACCOUNT.LOGIN_NAME.likeRaw(pattern)
.or(SYS_ACCOUNT.NICKNAME.likeRaw(pattern));
QueryWrapper accountExists = QueryMethods.selectOne()
.from(SYS_ACCOUNT)
.where(SYS_ACCOUNT.ID.eq(SYS_LOG.ACCOUNT_ID))
.and(SYS_ACCOUNT.TENANT_ID.eq(account.getTenantId()))
.and(accountKeyword);
QueryCondition keywordCondition = SYS_LOG.ACTION_NAME.likeRaw(pattern)
.or(SYS_LOG.ACTION_IP.likeRaw(pattern))
.or(QueryMethods.exists(accountExists));
queryWrapper.and(keywordCondition);
}
Date createdStart = parseQueryTime(request.getParameter("createdStart"));
Date createdEnd = parseQueryTime(request.getParameter("createdEnd"));
if (createdStart != null && createdEnd != null && createdStart.after(createdEnd)) {
throw new BusinessException(400, 400, "操作时间范围不正确");
}
if (createdStart != null) {
queryWrapper.ge(SysLog::getCreated, createdStart);
}
if (createdEnd != null) {
queryWrapper.le(SysLog::getCreated, createdEnd);
}
return queryWrapper;
}
/**
* 按操作时间稳定倒序展示最新日志。
*
* @return 默认排序表达式
*/
@Override
protected String getDefaultOrderBy() {
return "created desc, id desc";
}
/**
* 限制日志单页记录数并加载操作账号关系。
*
* @param page 分页参数
* @param queryWrapper 查询条件
* @return 操作日志分页结果
*/
@Override @Override
@LogRecord("分页查询") @LogRecord("分页查询")
protected Page<SysLog> queryPage(Page<SysLog> page, QueryWrapper queryWrapper) { protected Page<SysLog> queryPage(Page<SysLog> page, QueryWrapper queryWrapper) {
page.setPageSize(Math.min(page.getPageSize(), MAX_PAGE_SIZE));
RelationManager.setQueryRelations(Collections.singleton("account")); RelationManager.setQueryRelations(Collections.singleton("account"));
return service.getMapper().paginateWithRelations(page, queryWrapper); return service.getMapper().paginateWithRelations(page, queryWrapper);
} }
/**
* 解析日志查询时间。
*
* @param value 格式为 yyyy-MM-dd HH:mm:ss 的时间文本
* @return 解析后的时间;空文本返回 {@code null}
* @throws BusinessException 时间格式不正确时抛出
*/
private Date parseQueryTime(String value) {
if (!StringUtil.hasText(value)) {
return null;
}
try {
LocalDateTime dateTime = LocalDateTime.parse(value, QUERY_TIME_FORMATTER);
return Date.from(dateTime.atZone(ZoneId.systemDefault()).toInstant());
} catch (DateTimeParseException exception) {
throw new BusinessException(
400, 400, "操作时间格式不正确", exception);
}
}
} }

View File

@@ -41,7 +41,7 @@ public class SysPositionController extends BaseCurdController<SysPositionService
/** /**
* 分页查询岗位列表 * 分页查询岗位列表
* <p> * <p>
* 支持按岗位名称模糊查询,状态、编码精确查询。 * 支持按岗位名称、岗位编码统一模糊查询,状态保持精确查询。
* </p> * </p>
* *
* @param request 请求对象 * @param request 请求对象
@@ -67,18 +67,23 @@ public class SysPositionController extends BaseCurdController<SysPositionService
.from(SYS_POSITION); .from(SYS_POSITION);
// 获取查询参数 // 获取查询参数
String keyword = normalizeSearchKeyword(request.getParameter("keyword"));
String positionName = request.getParameter("positionName"); String positionName = request.getParameter("positionName");
String positionCode = request.getParameter("positionCode"); String positionCode = request.getParameter("positionCode");
String status = request.getParameter("status"); String status = request.getParameter("status");
// 岗位名称 - 模糊查询 if (StringUtil.hasText(keyword)) {
queryWrapper.and(buildLiteralContainsCondition(
keyword, SYS_POSITION.POSITION_NAME, SYS_POSITION.POSITION_CODE));
} else {
// 兼容仍按旧参数调用的客户端。
if (StringUtil.hasText(positionName)) { if (StringUtil.hasText(positionName)) {
queryWrapper.where(SYS_POSITION.POSITION_NAME.like(positionName)); queryWrapper.and(buildLiteralContainsCondition(positionName, SYS_POSITION.POSITION_NAME));
} }
// 岗位编码 - 精确查询
if (StringUtil.hasText(positionCode)) { if (StringUtil.hasText(positionCode)) {
queryWrapper.where(SYS_POSITION.POSITION_CODE.eq(positionCode)); queryWrapper.where(SYS_POSITION.POSITION_CODE.eq(positionCode));
} }
}
// 状态 - 精确查询 // 状态 - 精确查询
if (StringUtil.hasText(status)) { if (StringUtil.hasText(status)) {
queryWrapper.where(SYS_POSITION.STATUS.eq(status)); queryWrapper.where(SYS_POSITION.STATUS.eq(status));

View File

@@ -9,6 +9,7 @@ import tech.easyflow.common.domain.Result;
import tech.easyflow.common.satoken.util.SaTokenUtil; import tech.easyflow.common.satoken.util.SaTokenUtil;
import tech.easyflow.common.web.exceptions.BusinessException; import tech.easyflow.common.web.exceptions.BusinessException;
import tech.easyflow.common.web.jsonbody.JsonBody; import tech.easyflow.common.web.jsonbody.JsonBody;
import tech.easyflow.admin.service.system.SystemFormOptionService;
import tech.easyflow.system.entity.vo.SysRoleCategoryScopeDetailVo; import tech.easyflow.system.entity.vo.SysRoleCategoryScopeDetailVo;
import tech.easyflow.system.service.CategoryPermissionService; import tech.easyflow.system.service.CategoryPermissionService;
import tech.easyflow.system.service.SysRoleCategoryScopeService; import tech.easyflow.system.service.SysRoleCategoryScopeService;
@@ -25,6 +26,8 @@ public class SysRoleCategoryScopeController {
@Resource @Resource
private CategoryPermissionService categoryPermissionService; private CategoryPermissionService categoryPermissionService;
@Resource
private SystemFormOptionService systemFormOptionService;
@GetMapping("/detail") @GetMapping("/detail")
@SaCheckPermission("/api/v1/sysRole/query") @SaCheckPermission("/api/v1/sysRole/query")
@@ -41,6 +44,7 @@ public class SysRoleCategoryScopeController {
if (request == null || request.getRoleId() == null) { if (request == null || request.getRoleId() == null) {
throw new BusinessException("角色ID不能为空"); throw new BusinessException("角色ID不能为空");
} }
systemFormOptionService.validateCategoryScopes(request.getScopes());
BigInteger operatorId = SaTokenUtil.getLoginAccount().getId(); BigInteger operatorId = SaTokenUtil.getLoginAccount().getId();
sysRoleCategoryScopeService.saveRoleScopes(request.getRoleId(), request.getScopes(), operatorId); sysRoleCategoryScopeService.saveRoleScopes(request.getRoleId(), request.getScopes(), operatorId);
return Result.ok(); return Result.ok();

View File

@@ -9,6 +9,8 @@ import tech.easyflow.common.entity.LoginAccount;
import tech.easyflow.common.satoken.util.SaTokenUtil; import tech.easyflow.common.satoken.util.SaTokenUtil;
import tech.easyflow.common.web.controller.BaseCurdController; import tech.easyflow.common.web.controller.BaseCurdController;
import tech.easyflow.common.web.jsonbody.JsonBody; import tech.easyflow.common.web.jsonbody.JsonBody;
import tech.easyflow.admin.model.SystemFormOptionsView;
import tech.easyflow.admin.service.system.SystemFormOptionService;
import tech.easyflow.system.entity.SysRole; import tech.easyflow.system.entity.SysRole;
import tech.easyflow.system.entity.SysRoleDept; import tech.easyflow.system.entity.SysRoleDept;
import tech.easyflow.system.entity.SysRoleMenu; import tech.easyflow.system.entity.SysRoleMenu;
@@ -38,11 +40,34 @@ public class SysRoleController extends BaseCurdController<SysRoleService, SysRol
private SysRoleMenuService sysRoleMenuService; private SysRoleMenuService sysRoleMenuService;
@Resource @Resource
private SysRoleDeptService sysRoleDeptService; private SysRoleDeptService sysRoleDeptService;
@Resource
private SystemFormOptionService systemFormOptionService;
public SysRoleController(SysRoleService service) { public SysRoleController(SysRoleService service) {
super(service); super(service);
} }
/**
* 获取角色列表关键字搜索字段。
*
* @return 角色名称和角色标识属性
*/
@Override
protected String[] getKeywordSearchProperties() {
return new String[]{"roleName", "roleKey"};
}
/**
* 查询角色表单所需的菜单和非 Bot 分类选项。
*
* @return 角色表单安全选项
*/
@GetMapping("formOptions")
@SaCheckPermission("/api/v1/sysRole/query")
public Result<SystemFormOptionsView.RoleFormOptions> formOptions() {
return Result.ok(systemFormOptionService.roleFormOptions());
}
@PostMapping("saveRoleMenu/{roleId}") @PostMapping("saveRoleMenu/{roleId}")
@SaCheckPermission("/api/v1/sysRole/save") @SaCheckPermission("/api/v1/sysRole/save")
@Deprecated @Deprecated
@@ -85,6 +110,7 @@ public class SysRoleController extends BaseCurdController<SysRoleService, SysRol
if (entity.getId() == null) { if (entity.getId() == null) {
commonFiled(entity, loginUser.getId(), loginUser.getTenantId(), loginUser.getDeptId()); commonFiled(entity, loginUser.getId(), loginUser.getTenantId(), loginUser.getDeptId());
} }
systemFormOptionService.validateRoleReferences(entity);
service.saveRole(entity); service.saveRole(entity);
return Result.ok(entity.getId()); return Result.ok(entity.getId());
} }

View File

@@ -24,6 +24,16 @@ public class SysUserFeedbackController extends BaseCurdController<SysUserFeedbac
super(service); super(service);
} }
/**
* 获取用户反馈关键字搜索字段。
*
* @return 反馈内容和联系方式属性
*/
@Override
protected String[] getKeywordSearchProperties() {
return new String[]{"feedbackContent", "contactInfo"};
}
@Override @Override
protected Result<?> onSaveOrUpdateBefore(SysUserFeedback entity, boolean isSave) { protected Result<?> onSaveOrUpdateBefore(SysUserFeedback entity, boolean isSave) {
if (!isSave) { if (!isSave) {

View File

@@ -0,0 +1,76 @@
package tech.easyflow.admin.controller.system.vo;
import cn.hutool.core.bean.BeanUtil;
import tech.easyflow.system.entity.SysAccount;
import java.util.ArrayList;
import java.util.List;
/**
* 管理端当前账号资料视图。
*/
public class SysAccountProfileVo extends SysAccount {
private String homePath;
private List<String> roles = new ArrayList<>();
/**
* 创建空的当前账号资料视图。
*/
public SysAccountProfileVo() {
}
/**
* 根据账号实体和角色信息创建资料视图。
*
* @param account 账号实体
* @param roles 角色标识列表
* @param homePath 默认首页
* @return 当前账号资料视图
*/
public static SysAccountProfileVo from(SysAccount account, List<String> roles, String homePath) {
SysAccountProfileVo profile = new SysAccountProfileVo();
if (account != null) {
BeanUtil.copyProperties(account, profile);
}
profile.setRoles(roles);
profile.setHomePath(homePath);
return profile;
}
/**
* 获取默认首页。
*
* @return 默认首页
*/
public String getHomePath() {
return homePath;
}
/**
* 设置默认首页。
*
* @param homePath 默认首页
*/
public void setHomePath(String homePath) {
this.homePath = homePath;
}
/**
* 获取角色标识列表。
*
* @return 角色标识列表
*/
public List<String> getRoles() {
return roles;
}
/**
* 设置角色标识列表。
*
* @param roles 角色标识列表
*/
public void setRoles(List<String> roles) {
this.roles = roles == null ? new ArrayList<>() : new ArrayList<>(roles);
}
}

View File

@@ -0,0 +1,20 @@
package tech.easyflow.admin.model;
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
import com.fasterxml.jackson.databind.ser.std.ToStringSerializer;
import java.math.BigInteger;
/**
* 定时任务可运行的工作流安全选项。
*
* @param id 工作流 ID
* @param title 工作流标题
* @param description 工作流描述
*/
public record SysJobWorkflowOptionView(
@JsonSerialize(using = ToStringSerializer.class) BigInteger id,
String title,
String description
) {
}

View File

@@ -0,0 +1,123 @@
package tech.easyflow.admin.model;
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
import com.fasterxml.jackson.databind.ser.std.ToStringSerializer;
import java.math.BigInteger;
import java.util.List;
import java.util.Map;
/**
* 管理端系统表单所需的安全选项视图。
*/
public final class SystemFormOptionsView {
private SystemFormOptionsView() {
}
/**
* 审批流程资源范围选项。
*
* @param categories 按资源类型分组的分类选项
* @param departments 部门树
*/
public record ApprovalResourceScopeOptions(
Map<String, List<CategoryOption>> categories,
List<DepartmentOption> departments
) {
}
/**
* 角色表单选项。
*
* @param menus 菜单树
* @param categories 按资源类型分组的非 Bot 分类选项
*/
public record RoleFormOptions(
List<MenuOption> menus,
Map<String, List<CategoryOption>> categories
) {
}
/**
* 账号表单选项。
*
* @param departments 部门树
* @param roles 可用角色
* @param positions 可用岗位
*/
public record AccountFormOptions(
List<DepartmentOption> departments,
List<RoleOption> roles,
List<PositionOption> positions
) {
}
/**
* 分类安全选项。
*
* @param id 分类 ID
* @param categoryName 分类名称
*/
public record CategoryOption(
@JsonSerialize(using = ToStringSerializer.class) BigInteger id,
String categoryName
) {
}
/**
* 部门树安全选项。
*
* @param id 部门 ID
* @param parentId 上级部门 ID
* @param deptName 部门名称
* @param children 下级部门
*/
public record DepartmentOption(
@JsonSerialize(using = ToStringSerializer.class) BigInteger id,
@JsonSerialize(using = ToStringSerializer.class) BigInteger parentId,
String deptName,
List<DepartmentOption> children
) {
}
/**
* 菜单树安全选项。
*
* @param id 菜单 ID
* @param parentId 上级菜单 ID
* @param menuTitle 菜单标题
* @param children 下级菜单
*/
public record MenuOption(
@JsonSerialize(using = ToStringSerializer.class) BigInteger id,
@JsonSerialize(using = ToStringSerializer.class) BigInteger parentId,
String menuTitle,
List<MenuOption> children
) {
}
/**
* 角色安全选项。
*
* @param id 角色 ID
* @param roleName 角色名称
*/
public record RoleOption(
@JsonSerialize(using = ToStringSerializer.class) BigInteger id,
String roleName
) {
}
/**
* 岗位安全选项。
*
* @param id 岗位 ID
* @param positionName 岗位名称
*/
public record PositionOption(
@JsonSerialize(using = ToStringSerializer.class) BigInteger id,
String positionName
) {
}
}

View File

@@ -0,0 +1,9 @@
package tech.easyflow.admin.model.ai;
/**
* 模型统一网关页面所需的安全配置。
*
* @param publishBaseUrl 模型发布基础地址
*/
public record ModelGatewayConfigView(String publishBaseUrl) {
}

View File

@@ -0,0 +1,180 @@
package tech.easyflow.admin.model.ai;
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
import com.fasterxml.jackson.databind.ser.std.ToStringSerializer;
import java.math.BigInteger;
import java.util.List;
import java.util.Map;
/**
* 工作流设计器所需的安全选项视图。
*
* @param models 模型选项
* @param knowledges 知识库选项
* @param codeEngines 代码执行引擎选项
*/
public record WorkflowDesignerOptionsView(
List<ModelOption> models,
List<KnowledgeOption> knowledges,
List<Map<String, Object>> codeEngines
) {
/**
* 模型安全选项。
*
* @param id 模型 ID
* @param title 模型标题
* @param description 模型描述
* @param modelProvider 供应商安全摘要
*/
public record ModelOption(
@JsonSerialize(using = ToStringSerializer.class) BigInteger id,
String title,
String description,
ProviderOption modelProvider
) {
}
/**
* 模型供应商安全摘要。
*
* @param providerName 供应商名称
* @param providerType 供应商类型
* @param icon 供应商图标
*/
public record ProviderOption(String providerName, String providerType, String icon) {
}
/**
* 知识库安全选项。
*
* @param id 知识库 ID
* @param title 知识库标题
* @param description 知识库描述
* @param vectorEmbedModelId Embedding 模型 ID
* @param dimensionOfVectorModel 向量维度
* @param vectorStoreEnabled 是否可用于向量检索
*/
public record KnowledgeOption(
@JsonSerialize(using = ToStringSerializer.class) BigInteger id,
String title,
String description,
@JsonSerialize(using = ToStringSerializer.class) BigInteger vectorEmbedModelId,
Integer dimensionOfVectorModel,
Boolean vectorStoreEnabled
) {
}
/**
* 插件安全选项。
*
* @param id 插件 ID
* @param name 插件名称
* @param description 插件描述
* @param icon 插件图标
* @param tools 可用工具
*/
public record PluginOption(
@JsonSerialize(using = ToStringSerializer.class) BigInteger id,
String name,
String description,
String icon,
List<PluginToolOption> tools
) {
}
/**
* 插件工具安全选项。
*
* @param id 工具 ID
* @param name 工具名称
* @param description 工具描述
*/
public record PluginToolOption(
@JsonSerialize(using = ToStringSerializer.class) BigInteger id,
String name,
String description
) {
}
/**
* 数据源安全选项。
*
* @param id 数据源 ID
* @param sourceName 数据源名称
* @param sourceType 数据源类型
*/
public record DataSourceOption(
@JsonSerialize(using = ToStringSerializer.class) BigInteger id,
String sourceName,
String sourceType
) {
}
/**
* 数据目录安全选项。
*
* @param id 目录 ID
* @param sourceId 数据源 ID
* @param catalogName 目录名称
* @param catalogDesc 目录描述
*/
public record CatalogOption(
@JsonSerialize(using = ToStringSerializer.class) BigInteger id,
@JsonSerialize(using = ToStringSerializer.class) BigInteger sourceId,
String catalogName,
String catalogDesc
) {
}
/**
* 已接入数据集安全选项。
*
* @param id 数据集 ID
* @param tenantId 租户 ID
* @param sourceId 数据源 ID
* @param catalogId 目录 ID
* @param tableName 数据表名称
* @param tableDesc 数据表描述
*/
public record DatasetOption(
@JsonSerialize(using = ToStringSerializer.class) BigInteger id,
@JsonSerialize(using = ToStringSerializer.class) BigInteger tenantId,
@JsonSerialize(using = ToStringSerializer.class) BigInteger sourceId,
@JsonSerialize(using = ToStringSerializer.class) BigInteger catalogId,
String tableName,
String tableDesc
) {
}
/**
* 数据集字段安全视图。
*
* @param fieldName 字段名称
* @param fieldDesc 字段描述
* @param jdbcType JDBC 类型
* @param fieldType 业务字段类型
*/
public record DatasetFieldOption(
String fieldName,
String fieldDesc,
String jdbcType,
Integer fieldType
) {
}
/**
* 数据集结构安全视图。
*
* @param tableName 数据表名称
* @param tableDesc 数据表描述
* @param fields 字段列表
*/
public record DatasetSchemaOption(
String tableName,
String tableDesc,
List<DatasetFieldOption> fields
) {
}
}

View File

@@ -17,8 +17,6 @@ public class DashboardDistributionItemVo {
private Long activeUserTotal; private Long activeUserTotal;
private Long botTotal;
private Long workflowTotal; private Long workflowTotal;
private Long knowledgeBaseTotal; private Long knowledgeBaseTotal;
@@ -73,14 +71,6 @@ public class DashboardDistributionItemVo {
this.activeUserTotal = activeUserTotal; this.activeUserTotal = activeUserTotal;
} }
public Long getBotTotal() {
return botTotal;
}
public void setBotTotal(Long botTotal) {
this.botTotal = botTotal;
}
public Long getWorkflowTotal() { public Long getWorkflowTotal() {
return workflowTotal; return workflowTotal;
} }

View File

@@ -9,7 +9,8 @@ public class DashboardSummaryVo {
private Long activeUserTotal; private Long activeUserTotal;
private Long botTotal; /** 智能体总数。 */
private Long agentTotal;
private Long workflowTotal; private Long workflowTotal;
@@ -39,12 +40,22 @@ public class DashboardSummaryVo {
this.activeUserTotal = activeUserTotal; this.activeUserTotal = activeUserTotal;
} }
public Long getBotTotal() { /**
return botTotal; * 获取智能体总数。
*
* @return 智能体总数
*/
public Long getAgentTotal() {
return agentTotal;
} }
public void setBotTotal(Long botTotal) { /**
this.botTotal = botTotal; * 设置智能体总数。
*
* @param agentTotal 智能体总数
*/
public void setAgentTotal(Long agentTotal) {
this.agentTotal = agentTotal;
} }
public Long getWorkflowTotal() { public Long getWorkflowTotal() {

View File

@@ -0,0 +1,488 @@
package tech.easyflow.admin.service.agent;
import com.mybatisflex.core.query.QueryWrapper;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import tech.easyflow.admin.dto.chatworkspace.*;
import tech.easyflow.agent.entity.Agent;
import tech.easyflow.agent.runtime.AgentRuntimeStateCleanupService;
import tech.easyflow.agent.runtime.composer.AgentComposerDraftService;
import tech.easyflow.agent.runtime.document.AgentDocumentService;
import tech.easyflow.agent.runtime.media.AgentMediaService;
import tech.easyflow.agent.service.AgentService;
import tech.easyflow.ai.entity.DocumentCollection;
import tech.easyflow.ai.enums.PublishStatus;
import tech.easyflow.ai.service.DocumentCollectionService;
import tech.easyflow.chatlog.domain.command.ChatSessionUpsertCommand;
import tech.easyflow.chatlog.domain.dto.*;
import tech.easyflow.chatlog.domain.query.ChatPageQuery;
import tech.easyflow.chatlog.service.ChatSessionCommandService;
import tech.easyflow.chatlog.service.ChatSessionQueryService;
import tech.easyflow.chatlog.support.ChatJsonSupport;
import tech.easyflow.common.entity.LoginAccount;
import tech.easyflow.common.web.exceptions.BusinessException;
import tech.easyflow.system.enums.CategoryResourceType;
import tech.easyflow.system.enums.ResourceAction;
import tech.easyflow.system.service.ResourceAccessService;
import javax.annotation.Resource;
import java.math.BigInteger;
import java.util.*;
/**
* Agent 管理端会话服务。
*/
@Service
public class AgentSessionService {
private static final String ASSISTANT_CODE = "AGENT";
private final ChatSessionQueryService chatSessionQueryService;
private final ChatSessionCommandService chatSessionCommandService;
private final AgentService agentService;
private final DocumentCollectionService documentCollectionService;
private final ResourceAccessService resourceAccessService;
private final AgentRuntimeStateCleanupService agentRuntimeStateCleanupService;
private final AgentMediaService agentMediaService;
private final AgentComposerDraftService agentComposerDraftService;
private final ChatJsonSupport chatJsonSupport;
@Resource
private AgentDocumentService agentDocumentService;
/**
* 创建 Agent 管理端会话服务。
*
* @param chatSessionQueryService 聊天会话查询服务
* @param chatSessionCommandService 聊天会话命令服务
* @param agentService Agent 服务
* @param documentCollectionService 知识库服务
* @param resourceAccessService 资源访问服务
* @param agentRuntimeStateCleanupService Agent 运行态清理服务
* @param agentMediaService Agent 媒体服务
* @param agentComposerDraftService Agent 输入草稿服务
* @param chatJsonSupport 聊天 JSON 工具
*/
public AgentSessionService(ChatSessionQueryService chatSessionQueryService,
ChatSessionCommandService chatSessionCommandService,
AgentService agentService,
DocumentCollectionService documentCollectionService,
ResourceAccessService resourceAccessService,
AgentRuntimeStateCleanupService agentRuntimeStateCleanupService,
AgentMediaService agentMediaService,
AgentComposerDraftService agentComposerDraftService,
ChatJsonSupport chatJsonSupport) {
this.chatSessionQueryService = chatSessionQueryService;
this.chatSessionCommandService = chatSessionCommandService;
this.agentService = agentService;
this.documentCollectionService = documentCollectionService;
this.resourceAccessService = resourceAccessService;
this.agentRuntimeStateCleanupService = agentRuntimeStateCleanupService;
this.agentMediaService = agentMediaService;
this.agentComposerDraftService = agentComposerDraftService;
this.chatJsonSupport = chatJsonSupport;
}
/**
* 查询当前用户的 Agent 会话分页。
*
* @param account 当前登录账号
* @param agentId Agent ID
* @param query 分页参数
* @return Agent 会话分页
*/
public ChatWorkspaceSessionPage queryCurrentUserSessions(LoginAccount account, BigInteger agentId, ChatPageQuery query) {
ChatSessionPage page = chatSessionQueryService.pageSessions(account.getId(), agentId, ASSISTANT_CODE, query);
Map<BigInteger, AgentAvailability> availabilityMap = resolveAgentAvailability(page.getRecords());
ChatWorkspaceSessionPage result = new ChatWorkspaceSessionPage();
result.setTotal(page.getTotal());
result.setPageNumber(page.getPageNumber());
result.setPageSize(page.getPageSize());
List<ChatWorkspaceSessionView> records = new ArrayList<>();
for (ChatSessionSummary summary : page.getRecords()) {
records.add(toSessionView(summary, availabilityMap.get(summary.getAssistantId())));
}
result.setRecords(records);
return result;
}
/**
* 查询当前用户的 Agent 会话详情。
*
* @param account 当前登录账号
* @param sessionId 会话 ID
* @return Agent 会话详情
*/
public ChatWorkspaceSessionDetailView getCurrentUserSession(LoginAccount account, BigInteger sessionId) {
ChatSessionSummary summary = requireUserAgentSession(account, sessionId);
AgentAvailability availability = resolveAgentAvailability(List.of(summary)).get(summary.getAssistantId());
ChatWorkspaceSessionDetailView detail = new ChatWorkspaceSessionDetailView();
fillSessionView(detail, summary, availability);
Agent displayAgent = availability == null ? null : availability.displayAgent();
detail.setAssistant(toAssistantView(displayAgent, summary));
detail.setBoundKnowledges(resolveBoundKnowledges(displayAgent));
ExtraKnowledgeResolution extraKnowledgeResolution = resolveExtraKnowledges(summary);
detail.setExtraKnowledges(extraKnowledgeResolution.validKnowledges());
detail.setRemovedExtraKnowledgeNames(extraKnowledgeResolution.removedNames());
if (extraKnowledgeResolution.shouldSync()) {
syncSessionExtraKnowledges(summary, extraKnowledgeResolution.validKnowledgeIds(), account.getId());
}
return detail;
}
/**
* 查询当前用户的 Agent 会话消息。
*
* @param account 当前登录账号
* @param sessionId 会话 ID
* @param query 分页参数
* @return 消息分页
*/
public ChatHistoryPage queryCurrentUserMessages(LoginAccount account, BigInteger sessionId, ChatPageQuery query) {
requireUserAgentSession(account, sessionId);
return chatSessionQueryService.pageMainlineMessages(sessionId, query);
}
/**
* 查询当前用户的 Agent 完整会话。
*
* @param account 当前登录账号
* @param sessionId 会话 ID
* @return 完整会话
*/
public ChatWorkspaceConversationView getCurrentUserConversation(LoginAccount account, BigInteger sessionId) {
requireUserAgentSession(account, sessionId);
List<ChatMessageRecord> records = chatSessionQueryService.listMainlineMessages(sessionId);
ChatWorkspaceConversationView view = new ChatWorkspaceConversationView();
view.setRecords(records);
view.setTotal(records.size());
return view;
}
/**
* 重命名当前用户的 Agent 会话。
*
* @param account 当前登录账号
* @param sessionId 会话 ID
* @param title 新标题
*/
public void renameCurrentUserSession(LoginAccount account, BigInteger sessionId, String title) {
if (!StringUtils.hasText(title)) {
throw new BusinessException("标题不能为空");
}
requireUserAgentSession(account, sessionId);
chatSessionCommandService.renameSession(sessionId, account.getId(), title.trim(), account.getId());
}
/**
* 保存当前用户 Agent 会话的临时知识库。
*
* @param account 当前登录账号
* @param sessionId 会话 ID
* @param knowledgeIds 临时知识库 ID
* @return 更新后的会话详情
*/
public ChatWorkspaceSessionDetailView saveCurrentUserExtraKnowledges(LoginAccount account,
BigInteger sessionId,
List<BigInteger> knowledgeIds) {
ChatSessionSummary summary = requireUserAgentSession(account, sessionId);
ExtraKnowledgeResolution resolution = resolveVisibleKnowledgeViews(normalizeExtraKnowledgeIds(knowledgeIds));
if (!resolution.removedNames().isEmpty()) {
throw new BusinessException("所选知识库已失效或无权限使用");
}
syncSessionExtraKnowledges(summary, resolution.validKnowledgeIds(), account.getId());
return getCurrentUserSession(account, sessionId);
}
/**
* 删除当前用户的 Agent 会话。
*
* @param account 当前登录账号
* @param sessionId 会话 ID
*/
public void deleteCurrentUserSession(LoginAccount account, BigInteger sessionId) {
ChatSessionSummary summary = chatSessionQueryService.getSessionSummary(sessionId);
if (summary == null || Integer.valueOf(1).equals(summary.getIsDeleted())) {
// 上一次删除可能已写入删除标记但媒体清理失败,重试时继续清理当前用户目录。
deleteComposerDraft(summary, account, sessionId);
agentMediaService.deleteFormalSession(sessionId.toString(), account);
deleteFormalDocuments(sessionId, account);
return;
}
requireUserAgentSession(account, summary);
agentRuntimeStateCleanupService.clearChatSession(sessionId, account.getId());
chatSessionCommandService.deleteSession(sessionId, account.getId(), account.getId());
deleteComposerDraft(summary, account, sessionId);
agentMediaService.deleteFormalSession(sessionId.toString(), account);
deleteFormalDocuments(sessionId, account);
}
/**
* 幂等清理正式会话绑定的文档对象与快照。
*
* @param sessionId 会话 ID
* @param account 当前账号
*/
private void deleteFormalDocuments(BigInteger sessionId, LoginAccount account) {
if (agentDocumentService != null) {
agentDocumentService.deleteFormalSession(sessionId.toString(), account);
}
}
/**
* 删除会话对应的未发送草稿和临时图片。
*
* @param summary 会话摘要
* @param account 当前登录账号
* @param sessionId 会话 ID
*/
private void deleteComposerDraft(ChatSessionSummary summary, LoginAccount account, BigInteger sessionId) {
if (summary == null || summary.getAssistantId() == null) {
return;
}
agentComposerDraftService.delete(AgentMediaService.MODE_FORMAL,
summary.getAssistantId().toString(), sessionId.toString(), account);
}
private ChatSessionSummary requireUserAgentSession(LoginAccount account, BigInteger sessionId) {
ChatSessionSummary summary = chatSessionQueryService.getSessionSummary(sessionId);
if (summary == null || Integer.valueOf(1).equals(summary.getIsDeleted())) {
throw new BusinessException("Agent 会话不存在");
}
requireUserAgentSession(account, summary);
return summary;
}
/**
* 校验会话属于当前用户且类型为 Agent。
*
* @param account 当前登录账号
* @param summary 会话摘要
* @throws BusinessException 会话类型不匹配或不属于当前用户时抛出
*/
private void requireUserAgentSession(LoginAccount account, ChatSessionSummary summary) {
if (!ASSISTANT_CODE.equals(summary.getAssistantCode())) {
throw new BusinessException("Agent 会话不存在");
}
if (!Objects.equals(summary.getUserId(), account.getId())) {
throw new BusinessException("无权访问该 Agent 会话");
}
if (!Objects.equals(summary.getTenantId(), account.getTenantId())) {
throw new BusinessException("无权访问该 Agent 会话");
}
}
private Map<BigInteger, AgentAvailability> resolveAgentAvailability(List<ChatSessionSummary> sessions) {
Map<BigInteger, AgentAvailability> result = new LinkedHashMap<>();
if (sessions == null || sessions.isEmpty()) {
return result;
}
Set<BigInteger> agentIds = new LinkedHashSet<>();
for (ChatSessionSummary session : sessions) {
if (session != null && session.getAssistantId() != null) {
agentIds.add(session.getAssistantId());
}
}
if (agentIds.isEmpty()) {
return result;
}
List<Agent> agents = agentService.list(QueryWrapper.create().in("id", agentIds));
Map<BigInteger, Agent> agentMap = new LinkedHashMap<>();
for (Agent agent : agents) {
agentMap.put(agent.getId(), agent);
}
for (BigInteger agentId : agentIds) {
Agent currentAgent = agentMap.get(agentId);
if (currentAgent == null) {
result.put(agentId, new AgentAvailability(false, ChatWorkspaceReadOnlyReason.ASSISTANT_DELETED, null));
continue;
}
if (!resourceAccessService.canAccess(CategoryResourceType.AGENT, currentAgent, ResourceAction.USE)) {
result.put(agentId, new AgentAvailability(false, ChatWorkspaceReadOnlyReason.NO_PERMISSION, null));
continue;
}
boolean online = Integer.valueOf(1).equals(currentAgent.getStatus())
&& PublishStatus.from(currentAgent.getPublishStatus()) == PublishStatus.PUBLISHED;
result.put(agentId, new AgentAvailability(
online,
online ? null : ChatWorkspaceReadOnlyReason.ASSISTANT_OFFLINE,
toDisplayAgent(currentAgent)
));
}
return result;
}
private Agent toDisplayAgent(Agent currentAgent) {
if (currentAgent.getPublishedSnapshotJson() != null && !currentAgent.getPublishedSnapshotJson().isEmpty()) {
return agentService.fromSnapshot(currentAgent.getPublishedSnapshotJson());
}
return currentAgent;
}
private ChatWorkspaceSessionView toSessionView(ChatSessionSummary summary, AgentAvailability availability) {
ChatWorkspaceSessionView view = new ChatWorkspaceSessionView();
fillSessionView(view, summary, availability);
return view;
}
private void fillSessionView(ChatWorkspaceSessionView view, ChatSessionSummary summary, AgentAvailability availability) {
view.setSessionId(summary.getId());
view.setAssistantId(summary.getAssistantId());
view.setAssistantCode(summary.getAssistantCode());
view.setAssistantName(summary.getAssistantName());
view.setTitle(summary.getTitle());
view.setLastMessagePreview(summary.getLastMessagePreview());
view.setMessageCount(summary.getMessageCount());
view.setAccessAt(summary.getAccessAt());
view.setLastMessageAt(summary.getLastMessageAt());
view.setContinuable(availability != null && availability.continuable());
view.setReadOnlyReason(availability == null ? ChatWorkspaceReadOnlyReason.ASSISTANT_DELETED : availability.reason());
}
private ChatWorkspaceAssistantView toAssistantView(Agent agent, ChatSessionSummary summary) {
ChatWorkspaceAssistantView view = new ChatWorkspaceAssistantView();
if (agent != null) {
view.setId(agent.getId());
view.setAlias(agent.getId() == null ? null : agent.getId().toString());
view.setTitle(agent.getName());
view.setDescription(agent.getDescription());
view.setIcon(agent.getAvatar());
return view;
}
view.setId(summary == null ? null : summary.getAssistantId());
view.setAlias(summary == null ? null : summary.getAssistantCode());
view.setTitle(summary == null ? null : summary.getAssistantName());
return view;
}
@SuppressWarnings("unchecked")
private List<ChatWorkspaceKnowledgeView> resolveBoundKnowledges(Agent displayAgent) {
if (displayAgent == null || displayAgent.getKnowledgeBindings() == null || displayAgent.getKnowledgeBindings().isEmpty()) {
return List.of();
}
List<BigInteger> knowledgeIds = displayAgent.getKnowledgeBindings().stream()
.map(binding -> binding.getKnowledgeId())
.filter(Objects::nonNull)
.toList();
if (knowledgeIds.isEmpty()) {
return List.of();
}
List<DocumentCollection> collections = documentCollectionService.listByIds(knowledgeIds);
Map<BigInteger, DocumentCollection> collectionMap = new LinkedHashMap<>();
for (DocumentCollection collection : collections) {
collectionMap.put(collection.getId(), collection);
}
List<ChatWorkspaceKnowledgeView> views = new ArrayList<>();
for (BigInteger knowledgeId : knowledgeIds) {
DocumentCollection collection = collectionMap.get(knowledgeId);
if (collection == null || PublishStatus.from(collection.getPublishStatus()) != PublishStatus.PUBLISHED) {
continue;
}
views.add(toKnowledgeView(documentCollectionService.toPublishedView(collection)));
}
return views;
}
private ChatWorkspaceKnowledgeView toKnowledgeView(DocumentCollection collection) {
ChatWorkspaceKnowledgeView view = new ChatWorkspaceKnowledgeView();
view.setId(collection.getId());
view.setAlias(collection.getAlias());
view.setTitle(collection.getTitle());
view.setDescription(collection.getDescription());
view.setIcon(collection.getIcon());
return view;
}
private ExtraKnowledgeResolution resolveExtraKnowledges(ChatSessionSummary summary) {
ChatSessionExtPayload payload = chatJsonSupport.fromJson(summary.getExtJson(), ChatSessionExtPayload.class);
List<BigInteger> extraKnowledgeIds = payload == null ? List.of() : payload.getExtraKnowledgeIds();
return resolveVisibleKnowledgeViews(extraKnowledgeIds);
}
private ExtraKnowledgeResolution resolveVisibleKnowledgeViews(List<BigInteger> knowledgeIds) {
if (knowledgeIds == null || knowledgeIds.isEmpty()) {
return new ExtraKnowledgeResolution(List.of(), List.of(), List.of(), false);
}
List<BigInteger> normalizedIds = normalizeExtraKnowledgeIds(knowledgeIds);
if (normalizedIds.isEmpty()) {
return new ExtraKnowledgeResolution(List.of(), List.of(), List.of(), false);
}
List<DocumentCollection> collections = documentCollectionService.listByIds(normalizedIds);
Map<BigInteger, DocumentCollection> collectionMap = new LinkedHashMap<>();
for (DocumentCollection collection : collections) {
collectionMap.put(collection.getId(), collection);
}
List<ChatWorkspaceKnowledgeView> validKnowledges = new ArrayList<>();
List<BigInteger> validKnowledgeIds = new ArrayList<>();
List<String> removedNames = new ArrayList<>();
boolean changed = false;
for (BigInteger knowledgeId : normalizedIds) {
DocumentCollection current = collectionMap.get(knowledgeId);
if (current == null) {
removedNames.add("知识库#" + knowledgeId);
changed = true;
continue;
}
if (PublishStatus.from(current.getPublishStatus()) != PublishStatus.PUBLISHED) {
removedNames.add(current.getTitle());
changed = true;
continue;
}
if (!resourceAccessService.canAccess(CategoryResourceType.KNOWLEDGE, current, ResourceAction.USE)) {
removedNames.add(current.getTitle());
changed = true;
continue;
}
validKnowledges.add(toKnowledgeView(documentCollectionService.toPublishedView(current)));
validKnowledgeIds.add(current.getId());
}
if (!Objects.equals(normalizedIds, validKnowledgeIds)) {
changed = true;
}
return new ExtraKnowledgeResolution(validKnowledges, validKnowledgeIds, removedNames, changed);
}
private List<BigInteger> normalizeExtraKnowledgeIds(List<BigInteger> knowledgeIds) {
if (knowledgeIds == null || knowledgeIds.isEmpty()) {
return List.of();
}
List<BigInteger> normalizedIds = new ArrayList<>();
for (BigInteger knowledgeId : knowledgeIds) {
if (knowledgeId != null && !normalizedIds.contains(knowledgeId)) {
normalizedIds.add(knowledgeId);
}
}
if (normalizedIds.size() > 3) {
throw new BusinessException("临时知识库最多选择 3 个");
}
return normalizedIds;
}
private void syncSessionExtraKnowledges(ChatSessionSummary summary, List<BigInteger> validKnowledgeIds, BigInteger operatorId) {
ChatSessionExtPayload payload = new ChatSessionExtPayload();
payload.setExtraKnowledgeIds(validKnowledgeIds);
ChatSessionUpsertCommand command = new ChatSessionUpsertCommand();
command.setSessionId(summary.getId());
command.setTenantId(summary.getTenantId());
command.setDeptId(summary.getDeptId());
command.setUserId(summary.getUserId());
command.setUserAccount(summary.getUserAccount());
command.setAssistantId(summary.getAssistantId());
command.setAssistantCode(summary.getAssistantCode());
command.setAssistantName(summary.getAssistantName());
command.setTitle(summary.getTitle());
command.setExtJson(chatJsonSupport.toJson(payload));
command.setOperatorId(operatorId);
chatSessionCommandService.createOrTouchSession(command);
}
private record AgentAvailability(boolean continuable,
ChatWorkspaceReadOnlyReason reason,
Agent displayAgent) {
}
private record ExtraKnowledgeResolution(List<ChatWorkspaceKnowledgeView> validKnowledges,
List<BigInteger> validKnowledgeIds,
List<String> removedNames,
boolean shouldSync) {
}
}

View File

@@ -166,8 +166,14 @@ public class ChatWorkspaceService {
roundIds.add(record.getRoundId()); roundIds.add(record.getRoundId());
} }
} }
List<ChatMessageRecord> allVariants = new ArrayList<>();
for (BigInteger roundId : roundIds) { for (BigInteger roundId : roundIds) {
variantsByRound.put(roundId.toString(), chatRoundOperateService.listVariants(sessionId, roundId)); List<ChatMessageRecord> variants = chatRoundOperateService.listVariantsUnprojected(sessionId, roundId);
variantsByRound.put(roundId.toString(), variants);
allVariants.addAll(variants);
}
if (!allVariants.isEmpty()) {
chatRoundOperateService.projectVariants(sessionId, allVariants);
} }
ChatWorkspaceConversationView view = new ChatWorkspaceConversationView(); ChatWorkspaceConversationView view = new ChatWorkspaceConversationView();
view.setRecords(records); view.setRecords(records);

View File

@@ -0,0 +1,648 @@
package tech.easyflow.admin.service.ai;
import com.alibaba.fastjson.JSON;
import com.easyagents.flow.core.chain.Chain;
import com.easyagents.flow.core.chain.ChainConsts;
import com.easyagents.flow.core.chain.ChainStatus;
import com.easyagents.flow.core.chain.Edge;
import com.easyagents.flow.core.chain.Event;
import com.easyagents.flow.core.chain.Node;
import com.easyagents.flow.core.chain.event.ChainStatusChangeEvent;
import com.easyagents.flow.core.chain.event.EdgeConditionCheckFailedEvent;
import com.easyagents.flow.core.chain.event.EdgeTriggerEvent;
import com.easyagents.flow.core.chain.event.NodeEndEvent;
import com.easyagents.flow.core.chain.event.NodeStartEvent;
import com.easyagents.flow.core.chain.runtime.ChainExecutor;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import javax.annotation.PostConstruct;
import javax.annotation.PreDestroy;
import java.io.IOException;
import java.time.Duration;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicLong;
/**
* 将工作流运行事件转换为对话页可消费的 SSE 事件流。
*/
@Service
public class WorkflowChatEventStream {
private static final Logger log =
LoggerFactory.getLogger(WorkflowChatEventStream.class);
private static final long SSE_TIMEOUT_MILLIS = 30L * 60L * 1000L;
private final ChainExecutor chainExecutor;
private final Map<String, StreamSession> sessions =
new ConcurrentHashMap<>();
private final ScheduledExecutorService detachedSessionCleaner =
Executors.newSingleThreadScheduledExecutor(task -> {
Thread thread = new Thread(
task,
"workflow-chat-detached-session-cleaner"
);
thread.setDaemon(true);
return thread;
});
/**
* 创建工作流对话事件流服务。
*
* @param chainExecutor 工作流执行器
*/
public WorkflowChatEventStream(ChainExecutor chainExecutor) {
this.chainExecutor = chainExecutor;
}
/**
* 注册工作流全局事件监听器。
*/
@PostConstruct
public void registerListeners() {
chainExecutor.addEventListener(this::onEvent);
chainExecutor.addErrorListener(this::onChainError);
}
/**
* 关闭断开会话清理线程并释放残留外部资源。
*/
@PreDestroy
public void shutdown() {
sessions.values().forEach(this::removeSession);
detachedSessionCleaner.shutdownNow();
}
/**
* 启动工作流并返回其 SSE 连接。
*
* @param definitionId 工作流定义 ID
* @param variables 运行变量
* @return SSE 连接
*/
public SseEmitter start(String definitionId, Map<String, Object> variables) {
return start(definitionId, variables, () -> {
});
}
/**
* 启动工作流并在流会话结束时执行清理回调。
*
* @param definitionId 工作流定义 ID
* @param variables 运行变量
* @param cleanup 终态、启动失败或连接断开后的幂等清理任务
* @return SSE 连接
*/
public SseEmitter start(
String definitionId,
Map<String, Object> variables,
Runnable cleanup
) {
return start(definitionId, variables, cleanup, Duration.ZERO);
}
/**
* 启动工作流并将浏览器连接与 Runtime 生命周期分离。
*
* <p>浏览器断开后不取消工作流;在保留期内继续监听真实终态并执行清理,
* 超过保留期时由租约兜底释放资源。</p>
*
* @param definitionId 工作流定义 ID
* @param variables 运行变量
* @param cleanup 终态、启动失败或保留期结束后的幂等清理任务
* @param detachedRetention 浏览器断开后的监听保留时长
* @return SSE 连接
*/
public SseEmitter start(
String definitionId,
Map<String, Object> variables,
Runnable cleanup,
Duration detachedRetention
) {
SseEmitter emitter = createEmitter();
StreamSession session = new StreamSession(
emitter,
cleanup,
detachedRetention
);
emitter.onTimeout(() -> detach(session));
emitter.onError(error -> detach(session));
emitter.onCompletion(() -> detach(session));
try {
chainExecutor.executeAsync(
definitionId,
variables,
executeId -> {
session.attach(executeId);
if (session.cleaned.get()) {
return;
}
sessions.put(executeId, session);
session.send("execution_started", Map.of(
"executeId", executeId
));
}
);
} catch (RuntimeException | Error error) {
session.fail(error);
throw error;
}
return emitter;
}
/**
* 创建 SSE 发送器,便于验证连接生命周期。
*/
SseEmitter createEmitter() {
return new SseEmitter(SSE_TIMEOUT_MILLIS);
}
/**
* 将工作流事件转发到对应执行流。
*
* @param event 工作流事件
* @param chain 当前工作流
*/
private void onEvent(Event event, Chain chain) {
StreamSession session = findSession(chain);
if (session == null) {
return;
}
if (event instanceof NodeStartEvent nodeStartEvent) {
session.onNodeStarted(chain, nodeStartEvent);
return;
}
if (event instanceof NodeEndEvent nodeEndEvent) {
session.onNodeFinished(chain, nodeEndEvent);
return;
}
if (event instanceof EdgeTriggerEvent edgeTriggerEvent) {
session.onEdgeTriggered(chain, edgeTriggerEvent);
return;
}
if (event instanceof EdgeConditionCheckFailedEvent failedEvent) {
session.onEdgeConditionFailed(chain, failedEvent);
return;
}
if (event instanceof ChainStatusChangeEvent statusEvent
&& Objects.equals(chain.getStateInstanceId(), session.executeId)) {
session.onStatusChanged(chain, statusEvent.getStatus());
}
}
/**
* 将链级异常发送到客户端。
*
* @param error 链级异常
* @param chain 当前工作流
*/
private void onChainError(Throwable error, Chain chain) {
StreamSession session = findSession(chain);
if (session != null
&& Objects.equals(chain.getStateInstanceId(), session.executeId)) {
session.send("execution_error", Map.of(
"message", safeErrorMessage(error)
));
}
}
/**
* 查找顶级执行对应的事件流会话。
*
* @param chain 当前工作流
* @return 流会话;不存在时为 {@code null}
*/
private StreamSession findSession(Chain chain) {
if (chain == null) {
return null;
}
String auditInstanceId = chain.getAuditInstanceId();
if (auditInstanceId != null && !auditInstanceId.isBlank()) {
StreamSession session = sessions.get(auditInstanceId);
if (session != null) {
return session;
}
}
return sessions.get(chain.getStateInstanceId());
}
/**
* 分离已经断开的浏览器传输,不影响工作流 Runtime。
*
* @param session 流会话
*/
private void detach(StreamSession session) {
if (session == null || session.terminal.get()) {
return;
}
session.detachTransport();
if (session.detachedRetention.isZero()
|| session.detachedRetention.isNegative()) {
removeSession(session);
return;
}
session.scheduleDetachedCleanup();
}
/**
* 移除流会话。
*
* @param session 流会话
*/
private void removeSession(StreamSession session) {
if (session != null && session.executeId != null) {
sessions.remove(session.executeId, session);
}
if (session != null) {
session.cleanup();
}
}
/**
* 读取适合返回给用户的异常信息。
*
* @param error 异常
* @return 非空异常信息
*/
private String safeErrorMessage(Throwable error) {
if (error == null || error.getMessage() == null
|| error.getMessage().isBlank()) {
return "工作流执行失败";
}
return error.getMessage();
}
/**
* 去掉顶级工作流结果中的内部状态控制字段。
*
* @param result 顶级工作流执行结果
* @return 可直接交给会话结果区展示的最终输出
*/
static Map<String, Object> visibleFinalOutput(
Map<String, Object> result
) {
Map<String, Object> visible = new LinkedHashMap<>();
if (result != null) {
visible.putAll(result);
}
visible.remove(ChainConsts.CHAIN_STATE_STATUS_KEY);
visible.remove(ChainConsts.CHAIN_STATE_MESSAGE_KEY);
visible.remove(ChainConsts.NODE_STATE_STATUS_KEY);
visible.remove(ChainConsts.SCHEDULE_NEXT_NODE_DISABLED_KEY);
return visible;
}
/**
* 单次工作流执行的 SSE 会话。
*/
private final class StreamSession {
private final SseEmitter emitter;
private final AtomicLong sequence = new AtomicLong();
private final AtomicBoolean terminal = new AtomicBoolean(false);
private final AtomicBoolean cleaned = new AtomicBoolean(false);
private final AtomicBoolean connected = new AtomicBoolean(true);
private final Runnable cleanup;
private final Duration detachedRetention;
private volatile ScheduledFuture<?> detachedCleanup;
private volatile String executeId;
/**
* 创建流会话。
*
* @param emitter SSE 发送器
*/
private StreamSession(
SseEmitter emitter,
Runnable cleanup,
Duration detachedRetention
) {
this.emitter = emitter;
this.cleanup = cleanup == null ? () -> {
} : cleanup;
this.detachedRetention = detachedRetention == null
? Duration.ZERO
: detachedRetention;
}
/**
* 幂等释放当前流持有的外部资源。
*/
private void cleanup() {
if (!cleaned.compareAndSet(false, true)) {
return;
}
cancelDetachedCleanup();
try {
cleanup.run();
} catch (RuntimeException error) {
log.warn(
"workflow chat stream cleanup failed, executeId={}",
executeId,
error
);
}
}
/**
* 绑定执行实例。
*
* @param executeId 执行实例 ID
*/
private void attach(String executeId) {
this.executeId = executeId;
}
/**
* 标记浏览器传输已经断开,后续事件只推进 Runtime 清理。
*/
private void detachTransport() {
connected.set(false);
}
/**
* 浏览器断开后按活动租约安排会话兜底清理。
*/
private synchronized void scheduleDetachedCleanup() {
if (detachedCleanup != null || cleaned.get()) {
return;
}
detachedCleanup = detachedSessionCleaner.schedule(
() -> removeSession(this),
Math.max(1L, detachedRetention.toMillis()),
TimeUnit.MILLISECONDS
);
}
/**
* 取消尚未触发的断开会话兜底任务。
*/
private synchronized void cancelDetachedCleanup() {
if (detachedCleanup == null) {
return;
}
detachedCleanup.cancel(false);
detachedCleanup = null;
}
/**
* 处理节点开始事件。
*
* @param chain 当前工作流
* @param event 节点开始事件
*/
private void onNodeStarted(Chain chain, NodeStartEvent event) {
Node node = event.getNode();
Map<String, Object> data = new LinkedHashMap<>();
data.put("attemptKey", event.getExecutionAttemptKey());
data.put("nodeClass", node.getClass().getSimpleName());
data.put("chainInstanceId", chain.getStateInstanceId());
data.put("startedAt", System.currentTimeMillis());
data.put("input", resolveNodeInput(chain, node));
send("node_started", nodePayload(node, data));
}
/**
* 处理节点完成事件并更新运行详情。
*
* @param chain 当前工作流
* @param event 节点完成事件
*/
private void onNodeFinished(Chain chain, NodeEndEvent event) {
Node node = event.getNode();
Map<String, Object> data = new LinkedHashMap<>();
data.put("attemptKey", event.getExecutionAttemptKey());
data.put("status", event.getStatus() == null
? null
: event.getStatus().name());
data.put("chainInstanceId", chain.getStateInstanceId());
data.put("finishedAt", System.currentTimeMillis());
data.put("output", event.getResult() == null
? Map.of()
: event.getResult());
if (event.getError() != null) {
data.put("error", safeErrorMessage(event.getError()));
}
send("node_finished", nodePayload(node, data));
}
/**
* 记录命中条件的流转分支。
*
* @param chain 当前工作流
* @param event 边触发事件
*/
private void onEdgeTriggered(Chain chain, EdgeTriggerEvent event) {
if (event.getTrigger() == null) {
return;
}
Edge edge = chain.getDefinition().getEdgeById(
event.getTrigger().getEdgeId());
if (edge == null || edge.getCondition() == null) {
return;
}
Node sourceNode = chain.getDefinition().getNodeById(
edge.getSource());
sendEdgeTrace(chain, sourceNode, edge, "matched");
}
/**
* 记录未命中条件的流转分支。
*
* @param chain 当前工作流
* @param event 条件未命中事件
*/
private void onEdgeConditionFailed(
Chain chain,
EdgeConditionCheckFailedEvent event
) {
sendEdgeTrace(chain, event.getNode(), event.getEdge(), "skipped");
}
/**
* 发送节点的条件判断轨迹。
*
* @param chain 当前工作流
* @param sourceNode 条件来源节点
* @param edge 被判断的边
* @param outcome 判断结果
*/
private void sendEdgeTrace(
Chain chain,
Node sourceNode,
Edge edge,
String outcome
) {
if (sourceNode == null || edge == null) {
return;
}
Node targetNode = chain.getDefinition().getNodeById(
edge.getTarget());
Map<String, Object> data = new LinkedHashMap<>();
data.put(
"attemptKey",
chain.currentExecutionAttemptKey(sourceNode.getId()));
data.put("kind", "condition");
data.put("outcome", outcome);
data.put("edgeId", edge.getId());
data.put("targetNodeId", edge.getTarget());
data.put(
"targetNodeName",
targetNode == null ? edge.getTarget() : targetNode.getName());
send("node_trace", nodePayload(sourceNode, data));
}
/**
* 解析节点本次执行实际使用的输入。
*
* @param chain 当前工作流
* @param node 当前节点
* @return 可序列化的节点输入
*/
private Map<String, Object> resolveNodeInput(Chain chain, Node node) {
try {
return chain.getExecutionState()
.resolveParametersPreservingReferences(node);
} catch (RuntimeException error) {
log.warn(
"Failed to resolve workflow node input, "
+ "executeId={}, nodeId={}",
executeId,
node == null ? null : node.getId(),
error
);
return Map.of();
}
}
/**
* 处理工作流状态变化。
*
* @param chain 当前工作流
* @param status 新状态
*/
private void onStatusChanged(Chain chain, ChainStatus status) {
if (status == null) {
return;
}
if (status == ChainStatus.SUSPEND) {
Map<String, Object> data = new LinkedHashMap<>();
data.put("message", chain.getState().getMessage());
data.put(
"parameters",
chain.getState().getSuspendForParameters()
);
send("execution_waiting", data);
return;
}
if (!status.isTerminal()) {
send("execution_status", Map.of("status", status.name()));
return;
}
if (!terminal.compareAndSet(false, true)) {
return;
}
String eventType = switch (status) {
case SUCCEEDED -> "execution_finished";
case CANCELLED -> "execution_cancelled";
default -> "execution_failed";
};
Map<String, Object> data = new LinkedHashMap<>();
data.put("status", status.name());
data.put("message", chain.getState().getMessage());
if (status == ChainStatus.SUCCEEDED) {
data.put(
"output",
visibleFinalOutput(chain.getState().getExecuteResult())
);
}
send(eventType, data);
removeSession(this);
if (connected.compareAndSet(true, false)) {
emitter.complete();
}
}
/**
* 发送 SSE 事件。
*
* @param type 事件类型
* @param data 事件数据
*/
private void send(String type, Map<String, ?> data) {
if (!connected.get()) {
return;
}
long nextSequence = sequence.incrementAndGet();
Map<String, Object> payload = new LinkedHashMap<>();
payload.put("eventId", executeId + ":" + nextSequence);
payload.put("sequence", nextSequence);
payload.put("executeId", executeId);
payload.put("type", type);
payload.put("data", data);
try {
emitter.send(SseEmitter.event()
.id(String.valueOf(nextSequence))
.name("workflow")
// 显式发送 JSON 文本,避免全局 CBOR 转换器将 SSE data 编码为二进制。
.data(JSON.toJSONString(payload)));
} catch (IOException | IllegalStateException error) {
log.debug(
"workflow chat stream disconnected, executeId={}",
executeId,
error
);
detach(this);
}
}
/**
* 在启动失败时关闭 SSE 会话。
*
* @param error 启动异常
*/
private void fail(Throwable error) {
if (terminal.compareAndSet(false, true)) {
send("execution_failed", Map.of(
"message", safeErrorMessage(error)
));
removeSession(this);
if (connected.compareAndSet(true, false)) {
emitter.completeWithError(error);
}
}
}
/**
* 构建带节点信息的事件数据。
*
* @param node 当前节点
* @param values 业务数据
* @return 事件数据
*/
private Map<String, Object> nodePayload(
Node node,
Map<String, ?> values
) {
Map<String, Object> payload = new LinkedHashMap<>();
payload.put("nodeId", node == null ? null : node.getId());
payload.put("nodeName", node == null ? null : node.getName());
if (values != null) {
payload.putAll(values);
}
return payload;
}
}
}

View File

@@ -0,0 +1,736 @@
package tech.easyflow.admin.service.ai;
import com.alibaba.fastjson2.JSON;
import com.alibaba.fastjson2.JSONArray;
import com.alibaba.fastjson2.JSONObject;
import com.easyagents.flow.core.chain.ChainDefinition;
import com.easyagents.flow.core.chain.Node;
import com.easyagents.flow.core.node.ConfirmNode;
import com.easyagents.flow.core.node.EndNode;
import com.easyagents.flow.core.node.StartNode;
import com.easyagents.flow.core.parser.ChainParser;
import com.mybatisflex.core.paginate.Page;
import com.mybatisflex.core.query.QueryWrapper;
import org.springframework.stereotype.Service;
import tech.easyflow.admin.model.ai.WorkflowDesignerOptionsView;
import tech.easyflow.ai.easyagentsflow.service.WorkflowDatacenterContentService;
import tech.easyflow.ai.easyagentsflow.knowledge.WorkflowKnowledgeContractService;
import tech.easyflow.ai.entity.DocumentCollection;
import tech.easyflow.ai.entity.Model;
import tech.easyflow.ai.entity.ModelProvider;
import tech.easyflow.ai.entity.Plugin;
import tech.easyflow.ai.entity.PluginItem;
import tech.easyflow.ai.entity.Workflow;
import tech.easyflow.ai.enums.PluginType;
import tech.easyflow.ai.plugin.workflow.snapshot.WorkflowPluginSnapshotResolver;
import tech.easyflow.ai.service.DocumentCollectionService;
import tech.easyflow.ai.service.ModelService;
import tech.easyflow.ai.service.PluginItemService;
import tech.easyflow.ai.service.PluginService;
import tech.easyflow.ai.service.PluginVisibilityService;
import tech.easyflow.ai.service.WorkflowService;
import tech.easyflow.ai.service.WorkflowUsageAuthorizationService;
import tech.easyflow.common.entity.LoginAccount;
import tech.easyflow.common.satoken.util.SaTokenUtil;
import tech.easyflow.common.web.exceptions.BusinessException;
import tech.easyflow.datacenter.entity.DatacenterTable;
import tech.easyflow.datacenter.entity.DatacenterTableField;
import tech.easyflow.datacenter.execution.model.DatacenterSchemaResponse;
import tech.easyflow.datacenter.execution.model.DatasetRef;
import tech.easyflow.datacenter.execution.service.DatacenterDatasetQueryService;
import tech.easyflow.datacenter.meta.entity.DatacenterSource;
import tech.easyflow.datacenter.meta.enums.DatacenterSourceType;
import tech.easyflow.datacenter.meta.model.DatacenterCatalogMeta;
import tech.easyflow.datacenter.meta.service.DatacenterDatasetRegistryService;
import tech.easyflow.datacenter.meta.service.DatacenterSourceService;
import tech.easyflow.system.enums.CategoryResourceType;
import tech.easyflow.system.enums.ResourceAction;
import tech.easyflow.system.service.ResourceAccessService;
import java.math.BigInteger;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
/**
* 查询工作流设计器所需的场景化安全选项。
*/
@Service
public class WorkflowDesignerOptionService {
private final ModelService modelService;
private final DocumentCollectionService documentCollectionService;
private final PluginService pluginService;
private final PluginItemService pluginItemService;
private final PluginVisibilityService pluginVisibilityService;
private final WorkflowService workflowService;
private final WorkflowUsageAuthorizationService workflowUsageAuthorizationService;
private final WorkflowPluginSnapshotResolver workflowPluginSnapshotResolver;
private final ChainParser chainParser;
private final WorkflowDatacenterContentService workflowDatacenterContentService;
private final ResourceAccessService resourceAccessService;
private final DatacenterSourceService datacenterSourceService;
private final DatacenterDatasetRegistryService datacenterDatasetRegistryService;
private final DatacenterDatasetQueryService datacenterDatasetQueryService;
private final WorkflowKnowledgeContractService workflowKnowledgeContractService;
/**
* 创建工作流设计器选项服务。
*
* @param modelService 模型服务
* @param documentCollectionService 知识库服务
* @param pluginService 插件服务
* @param pluginItemService 插件工具服务
* @param pluginVisibilityService 插件可见性服务
* @param workflowService 工作流服务
* @param workflowUsageAuthorizationService 工作流使用权限校验服务
* @param workflowPluginSnapshotResolver 工作流插件快照解析器
* @param chainParser 工作流解析器
* @param workflowDatacenterContentService 工作流数据中心内容服务
* @param resourceAccessService 资源访问服务
* @param datacenterSourceService 数据源服务
* @param datacenterDatasetRegistryService 数据集注册服务
* @param datacenterDatasetQueryService 数据集查询服务
* @param workflowKnowledgeContractService 工作流知识库契约服务
*/
public WorkflowDesignerOptionService(
ModelService modelService,
DocumentCollectionService documentCollectionService,
PluginService pluginService,
PluginItemService pluginItemService,
PluginVisibilityService pluginVisibilityService,
WorkflowService workflowService,
WorkflowUsageAuthorizationService workflowUsageAuthorizationService,
WorkflowPluginSnapshotResolver workflowPluginSnapshotResolver,
ChainParser chainParser,
WorkflowDatacenterContentService workflowDatacenterContentService,
ResourceAccessService resourceAccessService,
DatacenterSourceService datacenterSourceService,
DatacenterDatasetRegistryService datacenterDatasetRegistryService,
DatacenterDatasetQueryService datacenterDatasetQueryService,
WorkflowKnowledgeContractService workflowKnowledgeContractService) {
this.modelService = modelService;
this.documentCollectionService = documentCollectionService;
this.pluginService = pluginService;
this.pluginItemService = pluginItemService;
this.pluginVisibilityService = pluginVisibilityService;
this.workflowService = workflowService;
this.workflowUsageAuthorizationService = workflowUsageAuthorizationService;
this.workflowPluginSnapshotResolver = workflowPluginSnapshotResolver;
this.chainParser = chainParser;
this.workflowDatacenterContentService = workflowDatacenterContentService;
this.resourceAccessService = resourceAccessService;
this.datacenterSourceService = datacenterSourceService;
this.datacenterDatasetRegistryService = datacenterDatasetRegistryService;
this.datacenterDatasetQueryService = datacenterDatasetQueryService;
this.workflowKnowledgeContractService = workflowKnowledgeContractService;
}
/**
* 查询设计器初始化所需的模型和知识库。
*
* @param codeEngines 代码执行引擎选项
* @return 设计器初始化选项
*/
public WorkflowDesignerOptionsView listOptions(List<Map<String, Object>> codeEngines) {
LoginAccount account = requireAccount();
return new WorkflowDesignerOptionsView(
listModelOptions(account),
listKnowledgeOptions(account),
codeEngines == null ? List.of() : codeEngines
);
}
/**
* 校验工作流内容引用的场景资源是否仍允许当前账号使用。
*
* @param content 工作流内容
* @throws BusinessException 内容引用越权、跨租户或资源失效时抛出
*/
public void assertContentReferences(String content) {
if (content == null || content.isBlank()) {
return;
}
JSONObject root;
try {
root = JSON.parseObject(content);
} catch (Exception exception) {
throw new BusinessException("工作流内容不是合法JSON");
}
JSONArray nodes = root.getJSONArray("nodes");
if (nodes == null || nodes.isEmpty()) {
return;
}
LoginAccount account = requireAccount();
Set<BigInteger> modelIds = new HashSet<>();
Set<BigInteger> knowledgeIds = new HashSet<>();
List<List<BigInteger>> knowledgeGroups = new ArrayList<>();
Set<BigInteger> checkedPluginItemIds = new HashSet<>();
Set<BigInteger> checkedWorkflowIds = new HashSet<>();
Set<BigInteger> checkedSourceIds = new HashSet<>();
Set<String> checkedTableIds = new HashSet<>();
for (int index = 0; index < nodes.size(); index++) {
JSONObject node = nodes.getJSONObject(index);
if (node == null) {
continue;
}
JSONObject data = node.getJSONObject("data");
if (data == null) {
continue;
}
String nodeType = node.getString("type");
String dataType = data.getString("type");
if (nodeType != null && !nodeType.isBlank()
&& dataType != null && !dataType.isBlank()
&& !Objects.equals(nodeType, dataType)) {
throw new BusinessException("工作流节点类型与节点数据类型不一致");
}
if (nodeType == null || nodeType.isBlank()) {
nodeType = dataType;
}
if ("llmNode".equals(nodeType)) {
addReferenceId(modelIds, readReferenceId(data, "llmId", "模型"));
} else if ("knowledgeNode".equals(nodeType)) {
List<BigInteger> nodeKnowledgeIds = readKnowledgeReferenceIds(data);
knowledgeIds.addAll(nodeKnowledgeIds);
knowledgeGroups.add(nodeKnowledgeIds);
} else if ("plugin-node".equals(nodeType)) {
BigInteger pluginItemId = readReferenceId(data, "pluginId", "插件工具");
if (pluginItemId != null && checkedPluginItemIds.add(pluginItemId)) {
getPluginTinyFlowData(pluginItemId);
}
} else if ("workflow-node".equals(nodeType)) {
assertWorkflowReference(readReferenceId(data, "workflowId", "子流程"),
account, checkedWorkflowIds);
}
assertDatasetReference(data, account, checkedSourceIds, checkedTableIds);
}
assertModelReferences(modelIds, account);
assertKnowledgeReferences(knowledgeIds, account);
workflowKnowledgeContractService.assertMultiKnowledgeContracts(
knowledgeGroups, account.getTenantId());
}
/**
* 分页查询当前账号可用于工作流的插件。
*
* @param pageNumber 页码
* @param pageSize 每页数量
* @param keyword 插件名称或描述关键字
* @return 插件安全选项分页
*/
public Page<WorkflowDesignerOptionsView.PluginOption> pagePlugins(
Long pageNumber, Long pageSize, String keyword) {
LoginAccount account = requireAccount();
QueryWrapper wrapper = QueryWrapper.create()
.eq(Plugin::getTenantId, account.getTenantId().longValue())
.orderBy(Plugin::getCreated, false);
List<Plugin> plugins = pluginService.getMapper().selectListWithRelationsByQuery(wrapper);
String normalizedKeyword = keyword == null ? "" : keyword.trim().toLowerCase(java.util.Locale.ROOT);
List<Plugin> availablePlugins = pluginService.preparePluginsForCurrentUser(plugins, false, true).stream()
.filter(plugin -> matchesKeyword(normalizedKeyword, plugin.getName(), plugin.getDescription()))
.toList();
List<WorkflowDesignerOptionsView.PluginOption> options = availablePlugins.stream()
.map(this::toPluginOption)
.toList();
long actualPageNumber = pageNumber == null || pageNumber < 1 ? 1L : pageNumber;
long actualPageSize = pageSize == null || pageSize < 1 ? 10L : Math.min(pageSize, 100L);
int fromIndex = Math.toIntExact(Math.min(options.size(), (actualPageNumber - 1) * actualPageSize));
int toIndex = Math.toIntExact(Math.min(options.size(), fromIndex + actualPageSize));
return new Page<>(
options.subList(fromIndex, toIndex),
actualPageNumber,
actualPageSize,
options.size()
);
}
/**
* 判断任一候选文本是否包含关键字。
*
* @param keyword 已归一化的小写关键字
* @param values 候选文本
* @return 空关键字或任一文本命中时返回 {@code true}
*/
private boolean matchesKeyword(String keyword, String... values) {
if (keyword == null || keyword.isEmpty()) {
return true;
}
for (String value : values) {
if (value != null && value.toLowerCase(java.util.Locale.ROOT).contains(keyword)) {
return true;
}
}
return false;
}
/**
* 查询一个插件工具的工作流节点安全配置。
*
* @param pluginItemId 插件工具 ID
* @return 节点配置
* @throws BusinessException 插件工具不存在或不可用时抛出
*/
public JSONObject getPluginTinyFlowData(BigInteger pluginItemId) {
if (pluginItemId == null) {
throw new BusinessException("插件工具不能为空");
}
PluginItem record = pluginItemService.getById(pluginItemId);
if (record == null || !Integer.valueOf(1).equals(record.getStatus())) {
throw new BusinessException("插件工具不存在或已禁用");
}
Plugin plugin = pluginService.getById(record.getPluginId());
if (plugin == null) {
throw new BusinessException("插件不存在");
}
LoginAccount account = requireAccount();
if (!Objects.equals(plugin.getTenantId(), account.getTenantId().longValue())) {
throw new BusinessException("无权限访问插件");
}
pluginVisibilityService.assertPluginVisible(plugin.getCreatedBy(), plugin.getId(), "无权限访问插件");
Plugin preparedPlugin = pluginService.preparePluginForCurrentUser(plugin);
if (Boolean.FALSE.equals(preparedPlugin.getAvailable())) {
throw new BusinessException(preparedPlugin.getReasonMessage());
}
JSONObject nodeData = new JSONObject();
nodeData.put("pluginId", record.getId().toString());
nodeData.put("pluginName", record.getName());
nodeData.put("pluginType", preparedPlugin.getType());
nodeData.put("workflowId", preparedPlugin.getWorkflowId());
nodeData.put("workflowTitle", preparedPlugin.getWorkflowTitle());
nodeData.put("available", preparedPlugin.getAvailable());
nodeData.put("reasonCode", preparedPlugin.getReasonCode());
nodeData.put("reasonMessage", preparedPlugin.getReasonMessage());
nodeData.put("parameters", parseSchemaArray(record.getInputData()));
nodeData.put("outputDefs", parseSchemaArray(record.getOutputData()));
nodeData.put("schemaHash", resolveSchemaHash(record, preparedPlugin));
return nodeData;
}
/**
* 查询子流程节点所需的输入输出定义。
*
* @param currentWorkflowId 当前工作流 ID
* @param childWorkflowId 子流程 ID
* @return 子流程节点配置
* @throws BusinessException 子流程不存在、不可用或无权使用时抛出
*/
public JSONObject getChildWorkflowNodeData(
BigInteger currentWorkflowId,
BigInteger childWorkflowId) {
if (childWorkflowId == null) {
throw new BusinessException("子流程不能为空");
}
if (Objects.equals(childWorkflowId, currentWorkflowId)) {
throw new BusinessException("工作流不能作为自身子节点");
}
LoginAccount account = requireAccount();
Workflow workflow = workflowUsageAuthorizationService.requireUsableWorkflow(
childWorkflowId,
account,
"子流程不存在、未发布或无权使用");
assertContentReferences(workflow.getContent());
ChainDefinition definition = chainParser.parse(
workflowDatacenterContentService.prepareContent(workflow.getContent()));
JSONArray inputs = new JSONArray();
JSONArray outputs = new JSONArray();
for (Node node : definition.getNodes()) {
if (node instanceof StartNode) {
inputs = JSON.parseArray(JSON.toJSONString(node.getParameters()));
decorateChildWorkflowSchemaArray(inputs);
}
if (node instanceof EndNode endNode) {
outputs = JSON.parseArray(JSON.toJSONString(endNode.getOutputDefs()));
decorateChildWorkflowSchemaArray(outputs);
}
if (node instanceof ConfirmNode) {
throw new BusinessException("工作流存在【确认节点】,暂不支持作为子节点");
}
}
JSONObject nodeData = new JSONObject();
nodeData.put("workflowId", workflow.getId());
nodeData.put("workflowName", workflow.getTitle());
nodeData.put("parameters", inputs);
nodeData.put("outputDefs", outputs);
return nodeData;
}
/**
* 查询当前租户可用于工作流的数据源安全选项。
*
* @return 数据源安全选项
*/
public List<WorkflowDesignerOptionsView.DataSourceOption> listDataSources() {
LoginAccount account = requireAccount();
datacenterDatasetRegistryService.ensureBuiltinSource(DatacenterSourceType.PROJECT_MYSQL, account);
return datacenterSourceService.list(QueryWrapper.create()
.eq(DatacenterSource::getTenantId, account.getTenantId())
.orderBy(DatacenterSource::getModified, false))
.stream()
.map(source -> new WorkflowDesignerOptionsView.DataSourceOption(
source.getId(), source.getSourceName(), source.getSourceType()))
.toList();
}
/**
* 查询一个数据源的目录安全选项。
*
* @param sourceId 数据源 ID
* @return 目录安全选项
*/
public List<WorkflowDesignerOptionsView.CatalogOption> listCatalogs(BigInteger sourceId) {
LoginAccount account = requireAccount();
requireTenantSource(sourceId, account);
return datacenterSourceService.listCatalogs(sourceId, account).stream()
.map(this::toCatalogOption)
.toList();
}
/**
* 查询一个数据源目录下已接入的数据集。
*
* @param sourceId 数据源 ID
* @param catalogId 目录 ID
* @return 数据集安全选项
*/
public List<WorkflowDesignerOptionsView.DatasetOption> listManagedDatasets(
BigInteger sourceId,
BigInteger catalogId) {
LoginAccount account = requireAccount();
requireTenantSource(sourceId, account);
return datacenterDatasetRegistryService.listManagedTables(sourceId, catalogId).stream()
.filter(table -> Objects.equals(table.getTenantId(), account.getTenantId()))
.filter(table -> Objects.equals(table.getSourceId(), sourceId))
.filter(table -> catalogId == null || Objects.equals(table.getCatalogId(), catalogId))
.map(this::toDatasetOption)
.toList();
}
/**
* 查询工作流数据节点所需的数据集结构。
*
* @param datasetRef 数据集引用
* @return 数据集结构安全视图
*/
public WorkflowDesignerOptionsView.DatasetSchemaOption getDatasetSchema(DatasetRef datasetRef) {
if (datasetRef == null || datasetRef.getSourceId() == null || datasetRef.getTableId() == null) {
throw new BusinessException("数据集引用不完整");
}
LoginAccount account = requireAccount();
requireTenantSource(datasetRef.getSourceId(), account);
DatacenterTable table = datacenterDatasetRegistryService.getTableWithFields(datasetRef.getTableId());
if (table == null
|| !Objects.equals(table.getTenantId(), account.getTenantId())
|| !Objects.equals(table.getSourceId(), datasetRef.getSourceId())) {
throw new BusinessException("数据集不存在或无权访问");
}
DatacenterSchemaResponse schema = datacenterDatasetQueryService.getSchema(datasetRef);
List<DatacenterTableField> fields = schema == null || schema.getFields() == null
? Collections.emptyList()
: schema.getFields();
return new WorkflowDesignerOptionsView.DatasetSchemaOption(
table.getTableName(),
table.getTableDesc(),
fields.stream()
.map(field -> new WorkflowDesignerOptionsView.DatasetFieldOption(
field.getFieldName(),
field.getFieldDesc(),
field.getJdbcType(),
field.getFieldType()
))
.toList()
);
}
private List<WorkflowDesignerOptionsView.ModelOption> listModelOptions(LoginAccount account) {
Model query = new Model();
query.setTenantId(account.getTenantId());
query.setModelType(Model.MODEL_TYPES[0]);
return modelService.listSelectableModels(query, false, "id", "desc").stream()
.filter(model -> Objects.equals(model.getTenantId(), account.getTenantId()))
.map(model -> {
ModelProvider provider = model.getModelProvider();
WorkflowDesignerOptionsView.ProviderOption providerOption = provider == null
? null
: new WorkflowDesignerOptionsView.ProviderOption(
provider.getProviderName(),
provider.getProviderType(),
provider.getIcon()
);
return new WorkflowDesignerOptionsView.ModelOption(
model.getId(),
model.getTitle(),
model.getDescription(),
providerOption
);
})
.toList();
}
private List<WorkflowDesignerOptionsView.KnowledgeOption> listKnowledgeOptions(LoginAccount account) {
List<DocumentCollection> collections = documentCollectionService.list(QueryWrapper.create()
.eq(DocumentCollection::getTenantId, account.getTenantId())
.orderBy(DocumentCollection::getModified, false));
Set<BigInteger> vectorReadyIds = workflowKnowledgeContractService
.findVectorReadyKnowledgeIds(collections, account.getTenantId());
return collections.stream()
.filter(item -> resourceAccessService.canAccess(
account, CategoryResourceType.KNOWLEDGE, item, ResourceAction.USE))
.map(item -> new WorkflowDesignerOptionsView.KnowledgeOption(
item.getId(),
item.getTitle(),
item.getDescription(),
item.getVectorEmbedModelId(),
item.getDimensionOfVectorModel(),
vectorReadyIds.contains(item.getId())))
.toList();
}
private List<BigInteger> readKnowledgeReferenceIds(JSONObject data) {
if (data.containsKey("knowledgeIds")) {
Object rawIds = data.get("knowledgeIds");
if (!(rawIds instanceof JSONArray ids) || ids.isEmpty()) {
throw new BusinessException("知识库节点至少需要选择一个知识库");
}
List<BigInteger> result = new ArrayList<>();
for (Object id : ids) {
BigInteger parsed = parseReferenceId(id, "知识库");
if (result.contains(parsed)) {
throw new BusinessException("知识库节点不能重复选择同一知识库");
}
result.add(parsed);
}
return result;
}
BigInteger legacyId = readReferenceId(data, "knowledgeId", "知识库");
return legacyId == null ? List.of() : List.of(legacyId);
}
private BigInteger parseReferenceId(Object value, String resourceName) {
if (value == null || String.valueOf(value).isBlank()) {
throw new BusinessException(resourceName + "ID不能为空");
}
try {
return new BigInteger(String.valueOf(value));
} catch (NumberFormatException exception) {
throw new BusinessException(resourceName + "ID无效");
}
}
private void addReferenceId(Set<BigInteger> resourceIds, BigInteger resourceId) {
if (resourceId != null) {
resourceIds.add(resourceId);
}
}
private void assertModelReferences(Set<BigInteger> modelIds, LoginAccount account) {
if (modelIds.isEmpty()) {
return;
}
List<Model> models = modelService.listByIds(modelIds);
boolean valid = models.size() == modelIds.size()
&& models.stream().allMatch(model ->
modelIds.contains(model.getId())
&& Objects.equals(model.getTenantId(), account.getTenantId())
&& Model.MODEL_TYPES[0].equals(model.getModelType()));
if (!valid) {
throw new BusinessException("模型不存在、已失效或无权使用");
}
}
private void assertKnowledgeReferences(Set<BigInteger> knowledgeIds, LoginAccount account) {
if (knowledgeIds.isEmpty()) {
return;
}
List<DocumentCollection> knowledges = documentCollectionService.listByIds(knowledgeIds);
boolean valid = knowledges.size() == knowledgeIds.size()
&& knowledges.stream().allMatch(knowledge ->
knowledgeIds.contains(knowledge.getId())
&& Objects.equals(knowledge.getTenantId(), account.getTenantId())
&& resourceAccessService.canAccess(
account, CategoryResourceType.KNOWLEDGE, knowledge, ResourceAction.USE));
if (!valid) {
throw new BusinessException("知识库不存在、已失效或无权使用");
}
}
private void assertWorkflowReference(
BigInteger workflowId,
LoginAccount account,
Set<BigInteger> checkedWorkflowIds) {
if (workflowId == null || !checkedWorkflowIds.add(workflowId)) {
return;
}
workflowUsageAuthorizationService.requireUsableWorkflow(
workflowId,
account,
"子流程不存在、未发布或无权使用");
}
private void assertDatasetReference(
JSONObject data,
LoginAccount account,
Set<BigInteger> checkedSourceIds,
Set<String> checkedTableIds) {
JSONObject datasetRef = data.getJSONObject("datasetRef");
if (datasetRef == null) {
return;
}
BigInteger sourceId = readReferenceId(datasetRef, "sourceId", "数据源");
if (sourceId != null && checkedSourceIds.add(sourceId)) {
requireTenantSource(sourceId, account);
}
BigInteger tableId = readReferenceId(datasetRef, "tableId", "数据集");
if (tableId == null) {
return;
}
if (sourceId == null) {
throw new BusinessException("数据集缺少数据源引用");
}
String checkedKey = sourceId + ":" + tableId;
if (!checkedTableIds.add(checkedKey)) {
return;
}
DatacenterTable table = datacenterDatasetRegistryService.getTableWithFields(tableId);
if (table == null
|| !Objects.equals(table.getTenantId(), account.getTenantId())
|| !Objects.equals(table.getSourceId(), sourceId)) {
throw new BusinessException("数据集不存在或无权使用");
}
}
private BigInteger readReferenceId(JSONObject data, String key, String resourceName) {
Object value = data.get(key);
if (value == null || String.valueOf(value).isBlank()) {
return null;
}
try {
return new BigInteger(String.valueOf(value));
} catch (NumberFormatException exception) {
throw new BusinessException(resourceName + "引用格式无效");
}
}
private WorkflowDesignerOptionsView.PluginOption toPluginOption(Plugin plugin) {
List<WorkflowDesignerOptionsView.PluginToolOption> tools =
plugin.getTools() == null ? List.of() : plugin.getTools().stream()
.filter(tool -> Integer.valueOf(1).equals(tool.getStatus()))
.map(tool -> new WorkflowDesignerOptionsView.PluginToolOption(
tool.getId(), tool.getName(), tool.getDescription()))
.toList();
return new WorkflowDesignerOptionsView.PluginOption(
plugin.getId(),
plugin.getName(),
plugin.getDescription(),
plugin.getIcon(),
tools
);
}
private JSONArray parseSchemaArray(String content) {
if (content == null || content.isBlank()) {
return new JSONArray();
}
JSONArray array = JSON.parseArray(content);
decorateSchemaArray(array);
return array;
}
private void decorateSchemaArray(JSONArray array) {
for (Object item : array) {
if (!(item instanceof JSONObject value)) {
continue;
}
value.put("id", cn.hutool.core.util.IdUtil.simpleUUID());
value.put("nameDisabled", true);
value.put("dataTypeDisabled", true);
value.put("deleteDisabled", true);
value.put("addChildDisabled", true);
JSONArray children = value.getJSONArray("children");
if (children != null) {
decorateSchemaArray(children);
}
}
}
/**
* 为子流程参数补充设计器只读元数据。
*
* @param array 子流程参数定义
*/
private void decorateChildWorkflowSchemaArray(JSONArray array) {
for (Object item : array) {
if (!(item instanceof JSONObject value)) {
continue;
}
value.put("id", cn.hutool.core.util.IdUtil.simpleUUID());
value.put("nameDisabled", true);
value.put("dataTypeDisabled", true);
value.put("deleteDisabled", true);
value.put("addChildDisabled", true);
value.put("refType", "ref");
JSONArray children = value.getJSONArray("children");
if (children != null) {
decorateChildWorkflowSchemaArray(children);
}
}
}
private String resolveSchemaHash(PluginItem record, Plugin plugin) {
if (record.getSchemaHash() != null && !record.getSchemaHash().isBlank()) {
return record.getSchemaHash();
}
if (!PluginType.isWorkflow(plugin.getType()) || plugin.getWorkflowId() == null) {
return null;
}
Workflow workflow = workflowService.getPublishedById(plugin.getWorkflowId());
return workflow == null ? null : workflowPluginSnapshotResolver.resolveSchemaHash(workflow);
}
private WorkflowDesignerOptionsView.CatalogOption toCatalogOption(DatacenterCatalogMeta catalog) {
return new WorkflowDesignerOptionsView.CatalogOption(
catalog.getId(),
catalog.getSourceId(),
catalog.getCatalogName(),
catalog.getCatalogDesc()
);
}
private WorkflowDesignerOptionsView.DatasetOption toDatasetOption(DatacenterTable table) {
return new WorkflowDesignerOptionsView.DatasetOption(
table.getId(),
table.getTenantId(),
table.getSourceId(),
table.getCatalogId(),
table.getTableName(),
table.getTableDesc()
);
}
private DatacenterSource requireTenantSource(BigInteger sourceId, LoginAccount account) {
if (sourceId == null) {
throw new BusinessException("数据源不能为空");
}
DatacenterSource source = datacenterSourceService.getById(sourceId);
if (source == null || !Objects.equals(source.getTenantId(), account.getTenantId())) {
throw new BusinessException("数据源不存在或无权访问");
}
return source;
}
private LoginAccount requireAccount() {
LoginAccount account = SaTokenUtil.getLoginAccount();
if (account == null || account.getId() == null || account.getTenantId() == null) {
throw new BusinessException("当前登录状态失效,请重新登录后再试");
}
return account;
}
}

View File

@@ -0,0 +1,165 @@
package tech.easyflow.admin.service.ai;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.script.DefaultRedisScript;
import org.springframework.stereotype.Component;
import tech.easyflow.common.cache.RedisLockExecutor;
import tech.easyflow.common.web.exceptions.BusinessException;
import java.math.BigInteger;
import java.time.Duration;
import java.util.List;
/**
* 工作流匿名分享的限流与活动执行互斥保护。
*/
@Component
public class WorkflowPublicChatAccessGuard {
private static final Logger log = LoggerFactory.getLogger(
WorkflowPublicChatAccessGuard.class);
private static final String KEY_PREFIX = "easyflow:workflow-public-share:";
private static final DefaultRedisScript<Long> RATE_LIMIT_SCRIPT;
static {
RATE_LIMIT_SCRIPT = new DefaultRedisScript<>();
RATE_LIMIT_SCRIPT.setScriptText(
"local visitor = redis.call('incr', KEYS[1]); "
+ "if visitor == 1 then redis.call('pexpire', KEYS[1], ARGV[3]); end; "
+ "local share = redis.call('incr', KEYS[2]); "
+ "if share == 1 then redis.call('pexpire', KEYS[2], ARGV[3]); end; "
+ "if visitor > tonumber(ARGV[1]) or share > tonumber(ARGV[2]) "
+ "then return 0 else return 1 end"
);
RATE_LIMIT_SCRIPT.setResultType(Long.class);
}
private final StringRedisTemplate redisTemplate;
private final RedisLockExecutor redisLockExecutor;
private final WorkflowPublicShareProperties properties;
public WorkflowPublicChatAccessGuard(
StringRedisTemplate redisTemplate,
RedisLockExecutor redisLockExecutor,
WorkflowPublicShareProperties properties
) {
this.redisTemplate = redisTemplate;
this.redisLockExecutor = redisLockExecutor;
this.properties = properties;
}
/**
* 检查匿名运行固定窗口限流。
*/
public void checkRun(BigInteger shareId, String visitorDigest) {
checkRate(
shareId,
visitorDigest,
"run",
properties.getRunVisitorLimit(),
properties.getRunShareLimit()
);
}
/**
* 检查匿名上传固定窗口限流。
*/
public void checkUpload(BigInteger shareId, String visitorDigest) {
checkRate(
shareId,
visitorDigest,
"upload",
properties.getUploadVisitorLimit(),
properties.getUploadShareLimit()
);
}
/**
* 获取同一分享访客的活动执行锁。
*
* @return 由 SSE 生命周期显式释放的锁句柄
*/
public RedisLockExecutor.LockHandle acquireActivity(
BigInteger shareId,
String visitorDigest
) {
try {
RedisLockExecutor.LockHandle handle = redisLockExecutor.tryAcquire(
KEY_PREFIX + "{" + shareId + "}:active:" + visitorDigest,
Duration.ZERO,
properties.getActiveLease()
);
if (handle == null) {
throw new BusinessException(
409,
40931,
"当前分享访客已有工作流正在运行"
);
}
return handle;
} catch (BusinessException exception) {
throw exception;
} catch (RuntimeException exception) {
log.error("匿名工作流活动锁暂不可用shareId={}", shareId, exception);
throw unavailable(exception);
}
}
/**
* 获取匿名活动执行锁的租约,用作浏览器断开后的监听保留上限。
*/
public Duration activityLease() {
return properties.getActiveLease();
}
private void checkRate(
BigInteger shareId,
String visitorDigest,
String action,
int visitorLimit,
int shareLimit
) {
String slot = "{" + shareId + "}";
List<String> keys = List.of(
KEY_PREFIX + slot + ":rate:" + action + ":visitor:" + visitorDigest,
KEY_PREFIX + slot + ":rate:" + action + ":share"
);
try {
Long allowed = redisTemplate.execute(
RATE_LIMIT_SCRIPT,
keys,
String.valueOf(visitorLimit),
String.valueOf(shareLimit),
String.valueOf(properties.getRateWindow().toMillis())
);
if (allowed == null) {
throw unavailable(new IllegalStateException(
"Redis 未返回匿名工作流限流结果"));
}
if (!Long.valueOf(1L).equals(allowed)) {
throw new BusinessException(
429,
42931,
"匿名工作流请求过于频繁,请稍后重试"
);
}
} catch (BusinessException exception) {
throw exception;
} catch (RuntimeException exception) {
log.error("匿名工作流限流暂不可用shareId={}, action={}",
shareId, action, exception);
throw unavailable(exception);
}
}
private BusinessException unavailable(RuntimeException cause) {
return new BusinessException(
503,
50331,
"匿名工作流保护服务暂不可用,请稍后重试",
cause
);
}
}

View File

@@ -0,0 +1,17 @@
package tech.easyflow.admin.service.ai;
import tech.easyflow.ai.entity.Workflow;
import tech.easyflow.ai.entity.WorkflowShare;
import tech.easyflow.common.entity.LoginAccount;
/**
* 完成匿名分享边界校验后的运行上下文。
*/
public record WorkflowPublicChatContext(
WorkflowShare share,
Workflow workflow,
LoginAccount creator,
String shareKey,
String visitorDigest
) {
}

View File

@@ -0,0 +1,121 @@
package tech.easyflow.admin.service.ai;
import com.mybatisflex.core.tenant.TenantManager;
import org.springframework.stereotype.Service;
import tech.easyflow.ai.entity.Workflow;
import tech.easyflow.ai.entity.WorkflowShare;
import tech.easyflow.ai.enums.PublishStatus;
import tech.easyflow.ai.service.WorkflowService;
import tech.easyflow.ai.service.WorkflowShareService;
import tech.easyflow.ai.share.WorkflowSharePolicy;
import tech.easyflow.common.constant.enums.EnumDataStatus;
import tech.easyflow.common.entity.LoginAccount;
import tech.easyflow.common.web.exceptions.BusinessException;
import tech.easyflow.system.entity.SysAccount;
import tech.easyflow.system.service.SysAccountService;
import java.util.Objects;
import java.util.regex.Pattern;
/**
* 解析并校验工作流匿名分享上下文。
*/
@Service
public class WorkflowPublicChatContextResolver {
private static final Pattern VISITOR_PATTERN = Pattern.compile("[a-f0-9]{32}");
private final WorkflowShareService shareService;
private final WorkflowService workflowService;
private final SysAccountService accountService;
public WorkflowPublicChatContextResolver(
WorkflowShareService shareService,
WorkflowService workflowService,
SysAccountService accountService
) {
this.shareService = shareService;
this.workflowService = workflowService;
this.accountService = accountService;
}
/**
* 解析新运行、恢复与上传所需的当前有效上下文。
*/
public WorkflowPublicChatContext resolveActive(
String shareKey,
String visitorId
) {
String normalizedVisitor = requireVisitor(visitorId);
WorkflowShare share = shareService.resolvePublicChatShare(shareKey);
Workflow workflow = TenantManager.withoutTenantCondition(
() -> workflowService.getPublishedById(share.getWorkflowId()));
if (!isStrictlyPublished(workflow)
|| !Objects.equals(share.getTenantId(), workflow.getTenantId())) {
throw new BusinessException(409, 409, "工作流尚未发布或已下线");
}
SysAccount account = TenantManager.withoutTenantCondition(
() -> accountService.getById(share.getCreatedBy()));
if (account == null
|| !EnumDataStatus.AVAILABLE.getCode().equals(account.getStatus())
|| !Objects.equals(share.getTenantId(), account.getTenantId())) {
throw new BusinessException(
403,
40331,
"工作流分享创建者账号当前不可用"
);
}
LoginAccount creator = account.toLoginAccount();
return new WorkflowPublicChatContext(
share,
workflow,
creator,
shareKey,
WorkflowSharePolicy.hashChatVisitor(
shareKey,
normalizedVisitor
)
);
}
/**
* 解析已发起执行的详情与取消所需历史上下文。
*/
public WorkflowPublicChatContext resolveHistorical(
String shareKey,
String visitorId
) {
String normalizedVisitor = requireVisitor(visitorId);
WorkflowShare share = shareService.resolveHistoricalChatShare(shareKey);
return new WorkflowPublicChatContext(
share,
null,
null,
shareKey,
WorkflowSharePolicy.hashChatVisitor(
shareKey,
normalizedVisitor
)
);
}
private String requireVisitor(String visitorId) {
String normalized = visitorId == null ? "" : visitorId.trim();
if (!VISITOR_PATTERN.matcher(normalized).matches()) {
throw new BusinessException(
400,
40031,
"工作流分享访客标识无效"
);
}
return normalized;
}
private boolean isStrictlyPublished(Workflow workflow) {
return workflow != null
&& PublishStatus.PUBLISHED.getCode().equals(
workflow.getPublishStatus())
&& workflow.getPublishedSnapshotJson() != null
&& !workflow.getPublishedSnapshotJson().isEmpty();
}
}

View File

@@ -0,0 +1,326 @@
package tech.easyflow.admin.service.ai;
import com.easyagents.flow.core.chain.ChainStatus;
import com.easyagents.flow.core.chain.ChainState;
import com.easyagents.flow.core.chain.runtime.ChainExecutor;
import com.mybatisflex.core.query.QueryWrapper;
import com.mybatisflex.core.tenant.TenantManager;
import org.springframework.stereotype.Service;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import tech.easyflow.ai.easyagentsflow.entity.WorkflowCheckStage;
import tech.easyflow.ai.easyagentsflow.service.WorkflowCheckService;
import tech.easyflow.ai.easyagentsflow.service.WorkflowRunningParameterResolver;
import tech.easyflow.ai.easyagentsflow.service.WorkflowResumeService;
import tech.easyflow.ai.easyagentsflow.support.PublishedWorkflowDefinitionIds;
import tech.easyflow.ai.entity.WorkflowExecResult;
import tech.easyflow.ai.entity.WorkflowExecStep;
import tech.easyflow.ai.service.WorkflowExecResultService;
import tech.easyflow.ai.service.WorkflowExecStepService;
import tech.easyflow.ai.utils.WorkFlowUtil;
import tech.easyflow.common.cache.RedisLockExecutor;
import tech.easyflow.common.constant.Constants;
import tech.easyflow.common.vo.UploadResVo;
import tech.easyflow.common.web.exceptions.BusinessException;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
/**
* 工作流匿名分享对话应用服务。
*/
@Service
public class WorkflowPublicChatService {
private static final Logger log =
LoggerFactory.getLogger(WorkflowPublicChatService.class);
private final WorkflowPublicChatContextResolver contextResolver;
private final WorkflowCheckService workflowCheckService;
private final WorkflowRunningParameterResolver parameterResolver;
private final WorkflowPublicChatUploadService uploadService;
private final WorkflowPublicChatAccessGuard accessGuard;
private final WorkflowChatEventStream eventStream;
private final ChainExecutor chainExecutor;
private final WorkflowResumeService workflowResumeService;
private final WorkflowExecResultService execResultService;
private final WorkflowExecStepService execStepService;
public WorkflowPublicChatService(
WorkflowPublicChatContextResolver contextResolver,
WorkflowCheckService workflowCheckService,
WorkflowRunningParameterResolver parameterResolver,
WorkflowPublicChatUploadService uploadService,
WorkflowPublicChatAccessGuard accessGuard,
WorkflowChatEventStream eventStream,
ChainExecutor chainExecutor,
WorkflowResumeService workflowResumeService,
WorkflowExecResultService execResultService,
WorkflowExecStepService execStepService
) {
this.contextResolver = contextResolver;
this.workflowCheckService = workflowCheckService;
this.parameterResolver = parameterResolver;
this.uploadService = uploadService;
this.accessGuard = accessGuard;
this.eventStream = eventStream;
this.chainExecutor = chainExecutor;
this.workflowResumeService = workflowResumeService;
this.execResultService = execResultService;
this.execStepService = execStepService;
}
/**
* 获取匿名分享的发布工作流描述。
*/
public Map<String, Object> descriptor(String shareKey, String visitorId) {
WorkflowPublicChatContext context = contextResolver.resolveActive(
shareKey, visitorId);
checkWorkflow(context);
Map<String, Object> descriptor = parameterResolver
.buildRunningParametersView(context.workflow());
if (descriptor == null) {
throw new BusinessException("工作流输入配置无法解析");
}
descriptor.put("workflowId", context.workflow().getId());
descriptor.put("publishStatus", context.workflow().getPublishStatus());
descriptor.put("shareable", false);
return descriptor;
}
/**
* 启动匿名分享工作流并返回 SSE。
*/
public SseEmitter run(
String shareKey,
String visitorId,
Map<String, Object> variables
) {
WorkflowPublicChatContext context = contextResolver.resolveActive(
shareKey, visitorId);
accessGuard.checkRun(
context.share().getId(),
context.visitorDigest()
);
checkWorkflow(context);
Map<String, Object> normalized = parameterResolver
.normalizeRuntimeVariables(
context.workflow().getContent(),
variables
);
uploadService.assertOwnedUploads(context, normalized);
normalized.put(Constants.LOGIN_USER_KEY, context.creator());
normalized.put(
WorkFlowUtil.CREATED_KEY_MEMORY_KEY,
WorkFlowUtil.publicChatShareCreatedKey(
context.share().getId())
);
normalized.put(
WorkFlowUtil.CREATED_BY_MEMORY_KEY,
context.visitorDigest()
);
RedisLockExecutor.LockHandle activity = accessGuard.acquireActivity(
context.share().getId(),
context.visitorDigest()
);
try {
return eventStream.start(
PublishedWorkflowDefinitionIds.published(
context.workflow().getId().toString()),
normalized,
activity::release,
accessGuard.activityLease()
);
} catch (RuntimeException | Error error) {
activity.release();
throw error;
}
}
/**
* 获取当前匿名访客发起的执行详情。
*/
public Map<String, Object> detail(
String shareKey,
String visitorId,
String executeId
) {
WorkflowPublicChatContext context = contextResolver.resolveHistorical(
shareKey, visitorId);
WorkflowExecResult record = assertExecutionOwnership(
context, executeId);
List<WorkflowExecStep> steps = TenantManager.withoutTenantCondition(
() -> execStepService.list(
QueryWrapper.create()
.eq(WorkflowExecStep::getRecordId, record.getId())
.orderBy(WorkflowExecStep::getStartTime, true)
));
return buildExecutionDetail(record, steps, runtimeView(executeId));
}
/**
* 取消当前匿名访客发起的执行。
*/
public boolean cancel(
String shareKey,
String visitorId,
String executeId
) {
WorkflowPublicChatContext context = contextResolver.resolveHistorical(
shareKey, visitorId);
assertExecutionOwnership(context, executeId);
return chainExecutor.cancel(executeId, "匿名访客已中止运行");
}
/**
* 恢复当前有效分享访客等待确认的执行。
*/
public void resume(
String shareKey,
String visitorId,
String executeId,
Map<String, Object> confirmParams
) {
WorkflowPublicChatContext context = contextResolver.resolveActive(
shareKey, visitorId);
assertExecutionOwnership(context, executeId);
workflowResumeService.resume(executeId, confirmParams);
}
/**
* 上传当前发布快照声明的匿名输入文件。
*/
public UploadResVo upload(
String shareKey,
String visitorId,
String parameterName,
MultipartFile file
) {
WorkflowPublicChatContext context = contextResolver.resolveActive(
shareKey, visitorId);
return uploadService.upload(context, parameterName, file);
}
private void checkWorkflow(WorkflowPublicChatContext context) {
TenantManager.withoutTenantCondition(() -> {
workflowCheckService.checkOrThrow(
context.workflow().getContent(),
WorkflowCheckStage.PRE_EXECUTE,
context.workflow().getId()
);
return null;
});
}
private WorkflowExecResult assertExecutionOwnership(
WorkflowPublicChatContext context,
String executeId
) {
if (executeId == null || executeId.isBlank()) {
throw new BusinessException("执行ID不能为空");
}
WorkflowExecResult record = TenantManager.withoutTenantCondition(
() -> execResultService.getByExecKey(executeId));
if (record == null) {
throw new BusinessException("工作流执行记录不存在,请稍后重试");
}
String expectedSource = WorkFlowUtil.publicChatShareCreatedKey(
context.share().getId());
if (!Objects.equals(expectedSource, record.getCreatedKey())
|| !Objects.equals(
context.visitorDigest(),
record.getCreatedBy())
|| !Objects.equals(
context.share().getWorkflowId(),
record.getWorkflowId())) {
throw new BusinessException(
403,
40333,
"无权限访问当前工作流执行记录"
);
}
return record;
}
private Map<String, Object> buildExecutionDetail(
WorkflowExecResult record,
List<WorkflowExecStep> steps,
Map<String, Object> runtime
) {
List<Map<String, Object>> stepViews = new ArrayList<>(steps.size());
for (WorkflowExecStep step : steps) {
Map<String, Object> view = new LinkedHashMap<>();
view.put("id", step.getId());
view.put("attemptKey", step.getExecKey());
view.put("nodeId", step.getNodeId());
view.put("nodeName", step.getNodeName());
view.put("input", step.getInput());
view.put("output", step.getOutput());
view.put("status", step.getStatus());
view.put("errorInfo", step.getErrorInfo());
view.put("startTime", step.getStartTime());
view.put("endTime", step.getEndTime());
view.put("execTime", step.getExecTime());
stepViews.add(view);
}
Map<String, Object> recordView = new LinkedHashMap<>();
recordView.put("executeId", record.getExecKey());
recordView.put("workflowId", record.getWorkflowId());
recordView.put("title", record.getTitle());
recordView.put("status", record.getStatus());
recordView.put("input", record.getInput());
recordView.put("output", record.getOutput());
recordView.put("errorInfo", record.getErrorInfo());
recordView.put("startTime", record.getStartTime());
recordView.put("endTime", record.getEndTime());
recordView.put("execTime", record.getExecTime());
Map<String, Object> detail = new LinkedHashMap<>();
detail.put("record", recordView);
detail.put("steps", stepViews);
detail.put("runtime", runtime);
return detail;
}
/**
* 构建刷新恢复所需的最小 Runtime 视图。
*/
private Map<String, Object> runtimeView(String executeId) {
try {
ChainState state = chainExecutor.getChainStateRepository()
.load(executeId);
if (state == null || state.getStatus() == null) {
return Map.of();
}
Map<String, Object> view = new LinkedHashMap<>();
view.put("status", state.getStatus().name());
view.put("statusValue", state.getStatus().getValue());
view.put("message", state.getMessage());
if (state.getStatus() == ChainStatus.SUSPEND) {
view.put("parameters", state.getSuspendForParameters());
}
if (state.getStatus() == ChainStatus.SUCCEEDED) {
view.put(
"output",
WorkflowChatEventStream.visibleFinalOutput(
state.getExecuteResult())
);
}
return view;
} catch (RuntimeException error) {
log.warn(
"failed to load public workflow runtime state, executeId={}",
executeId,
error
);
return Map.of();
}
}
}

View File

@@ -0,0 +1,325 @@
package tech.easyflow.admin.service.ai;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import org.springframework.web.multipart.MultipartFile;
import tech.easyflow.ai.easyagentsflow.service.WorkflowRunningParameterResolver;
import tech.easyflow.ai.share.WorkflowSharePolicy;
import tech.easyflow.common.filestorage.FileStorageService;
import tech.easyflow.common.vo.UploadResVo;
import tech.easyflow.common.web.exceptions.BusinessException;
import java.time.Duration;
import java.util.Collection;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.TimeUnit;
/**
* 工作流匿名分享的隔离上传与运行引用校验。
*/
@Service
public class WorkflowPublicChatUploadService {
private static final Logger log = LoggerFactory.getLogger(
WorkflowPublicChatUploadService.class);
private static final long FILE_MAX_SIZE = 100L * 1024L * 1024L;
private static final long IMAGE_MAX_SIZE = 10L * 1024L * 1024L;
private static final Set<String> IMAGE_MIME_TYPES = Set.of(
"image/bmp", "image/gif", "image/jpeg", "image/png", "image/webp");
private static final Set<String> IMAGE_EXTENSIONS = Set.of(
"bmp", "gif", "jpeg", "jpg", "png", "webp");
private static final String GRANT_PREFIX = "easyflow:workflow-public-share:upload:";
private final WorkflowRunningParameterResolver parameterResolver;
private final WorkflowPublicChatAccessGuard accessGuard;
private final WorkflowPublicShareProperties properties;
private final StringRedisTemplate redisTemplate;
private final FileStorageService storageService;
public WorkflowPublicChatUploadService(
WorkflowRunningParameterResolver parameterResolver,
WorkflowPublicChatAccessGuard accessGuard,
WorkflowPublicShareProperties properties,
StringRedisTemplate redisTemplate,
@Qualifier("default") FileStorageService storageService
) {
this.parameterResolver = parameterResolver;
this.accessGuard = accessGuard;
this.properties = properties;
this.redisTemplate = redisTemplate;
this.storageService = storageService;
}
/**
* 上传发布快照声明的文件或图片参数。
*/
public UploadResVo upload(
WorkflowPublicChatContext context,
String parameterName,
MultipartFile file
) {
String normalizedName = requireParameterName(parameterName);
String contentType = resolveUploadContentType(context, normalizedName);
validateFile(file, contentType);
accessGuard.checkUpload(
context.share().getId(),
context.visitorDigest()
);
String path = storageService.save(
file,
"workflow-chat-share/" + context.share().getId()
+ "/" + context.visitorDigest()
);
if (!StringUtils.hasText(path)) {
throw new BusinessException(503, 50332, "匿名文件上传失败,请稍后重试");
}
try {
redisTemplate.opsForValue().set(
grantKey(context, normalizedName, path),
contentType,
grantTtl(context).toMillis(),
TimeUnit.MILLISECONDS
);
} catch (RuntimeException exception) {
try {
storageService.delete(path);
} catch (RuntimeException cleanupError) {
log.warn("匿名上传授权写入失败后清理文件失败path={}",
path, cleanupError);
}
throw new BusinessException(
503,
50332,
"匿名上传保护服务暂不可用,请稍后重试",
exception
);
}
UploadResVo response = new UploadResVo();
response.setPath(path);
return response;
}
/**
* 校验公开运行引用的上传文件均属于当前分享访客和参数。
*/
public void assertOwnedUploads(
WorkflowPublicChatContext context,
Map<String, Object> variables
) {
Map<String, String> uploadFields = resolveUploadFields(context);
for (Map.Entry<String, String> entry : uploadFields.entrySet()) {
Object value = variables.get(entry.getKey());
if (value == null) {
continue;
}
if ("image".equals(entry.getValue())) {
assertOwnedImage(context, entry.getKey(), value);
} else {
assertOwnedFiles(context, entry.getKey(), value);
}
}
}
private void assertOwnedImage(
WorkflowPublicChatContext context,
String parameterName,
Object value
) {
if (!(value instanceof Map<?, ?> image)) {
throw invalidUploadReference(parameterName);
}
String sourceType = trim(image.get("sourceType"));
if ("url".equals(sourceType)) {
String url = trim(image.get("url"));
if (isHttpUrl(url)) {
return;
}
throw invalidUploadReference(parameterName);
}
if (!"upload".equals(sourceType)) {
throw invalidUploadReference(parameterName);
}
assertGrant(
context,
parameterName,
trim(image.get("filePath")),
"image"
);
}
private void assertOwnedFiles(
WorkflowPublicChatContext context,
String parameterName,
Object value
) {
if (!(value instanceof Collection<?> files)) {
throw invalidUploadReference(parameterName);
}
for (Object item : files) {
if (!(item instanceof Map<?, ?> file)) {
throw invalidUploadReference(parameterName);
}
assertGrant(
context,
parameterName,
trim(file.get("filePath")),
"file"
);
}
}
private void assertGrant(
WorkflowPublicChatContext context,
String parameterName,
String path,
String expectedContentType
) {
if (!StringUtils.hasText(path)) {
throw invalidUploadReference(parameterName);
}
try {
String grantedContentType = redisTemplate.opsForValue().get(
grantKey(context, parameterName, path));
if (!expectedContentType.equals(grantedContentType)) {
throw invalidUploadReference(parameterName);
}
} catch (BusinessException exception) {
throw exception;
} catch (RuntimeException exception) {
throw new BusinessException(
503,
50332,
"匿名上传保护服务暂不可用,请稍后重试",
exception
);
}
}
private String resolveUploadContentType(
WorkflowPublicChatContext context,
String parameterName
) {
String contentType = resolveUploadFields(context).get(parameterName);
if (contentType == null) {
throw new BusinessException(
400,
40032,
"当前发布工作流未声明该上传参数"
);
}
return contentType;
}
@SuppressWarnings("unchecked")
private Map<String, String> resolveUploadFields(
WorkflowPublicChatContext context
) {
Map<String, Object> descriptor = parameterResolver
.buildRunningParametersView(context.workflow());
if (descriptor == null) {
throw new BusinessException("工作流输入配置无法解析");
}
Map<String, String> fields = new java.util.LinkedHashMap<>();
Object rawSchema = descriptor.get("startFormSchema");
if (!(rawSchema instanceof Collection<?> schema)) {
return fields;
}
for (Object item : schema) {
if (!(item instanceof Map<?, ?> field)) {
continue;
}
String name = trim(field.get("key"));
String contentType = trim(field.get("contentType"));
if (StringUtils.hasText(name)
&& ("file".equals(contentType)
|| "image".equals(contentType))) {
fields.put(name, contentType);
}
}
return fields;
}
private void validateFile(MultipartFile file, String contentType) {
if (file == null || file.isEmpty()) {
throw new BusinessException("上传文件不能为空");
}
long maxSize = "image".equals(contentType)
? IMAGE_MAX_SIZE
: FILE_MAX_SIZE;
if (file.getSize() > maxSize) {
throw new BusinessException(
"image".equals(contentType)
? "单张图片不能超过 10 MiB"
: "单个文件不能超过 100 MiB"
);
}
if (!"image".equals(contentType)) {
return;
}
String mimeType = trim(file.getContentType()).toLowerCase(Locale.ROOT);
String filename = trim(file.getOriginalFilename());
int dot = filename.lastIndexOf('.');
String extension = dot < 0
? ""
: filename.substring(dot + 1).toLowerCase(Locale.ROOT);
if (!IMAGE_MIME_TYPES.contains(mimeType)
&& !IMAGE_EXTENSIONS.contains(extension)) {
throw new BusinessException("仅支持 PNG、JPEG、WebP、GIF、BMP 图片");
}
}
private Duration grantTtl(WorkflowPublicChatContext context) {
long expiresIn = context.share().getExpiresAt().getTime()
- System.currentTimeMillis();
long ttl = Math.min(
properties.getUploadGrantTtl().toMillis(),
expiresIn
);
return Duration.ofMillis(Math.max(1L, ttl));
}
private String grantKey(
WorkflowPublicChatContext context,
String parameterName,
String path
) {
return GRANT_PREFIX + "{" + context.share().getId() + "}:"
+ context.visitorDigest() + ":"
+ WorkflowSharePolicy.hashShareKey(parameterName) + ":"
+ WorkflowSharePolicy.hashShareKey(path);
}
private String requireParameterName(String value) {
String normalized = value == null ? "" : value.trim();
if (!StringUtils.hasText(normalized)) {
throw new BusinessException("上传参数名不能为空");
}
return normalized;
}
private String trim(Object value) {
return value == null ? "" : String.valueOf(value).trim();
}
private boolean isHttpUrl(String value) {
String normalized = value == null ? "" : value.toLowerCase(Locale.ROOT);
return normalized.startsWith("http://")
|| normalized.startsWith("https://");
}
private BusinessException invalidUploadReference(String parameterName) {
return new BusinessException(
403,
40332,
"上传参数 " + parameterName + " 不属于当前分享访客"
);
}
}

View File

@@ -0,0 +1,92 @@
package tech.easyflow.admin.service.ai;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
import java.time.Duration;
/**
* 工作流匿名分享运行保护参数。
*/
@Component
@ConfigurationProperties(prefix = "easyflow.workflow.public-share")
public class WorkflowPublicShareProperties {
private Duration rateWindow = Duration.ofMinutes(1);
private int runVisitorLimit = 5;
private int runShareLimit = 60;
private int uploadVisitorLimit = 10;
private int uploadShareLimit = 60;
private Duration activeLease = Duration.ofMinutes(35);
private Duration uploadGrantTtl = Duration.ofDays(7);
public Duration getRateWindow() {
return rateWindow;
}
public void setRateWindow(Duration rateWindow) {
this.rateWindow = requirePositive(rateWindow, "rateWindow");
}
public int getRunVisitorLimit() {
return runVisitorLimit;
}
public void setRunVisitorLimit(int runVisitorLimit) {
this.runVisitorLimit = requirePositive(runVisitorLimit, "runVisitorLimit");
}
public int getRunShareLimit() {
return runShareLimit;
}
public void setRunShareLimit(int runShareLimit) {
this.runShareLimit = requirePositive(runShareLimit, "runShareLimit");
}
public int getUploadVisitorLimit() {
return uploadVisitorLimit;
}
public void setUploadVisitorLimit(int uploadVisitorLimit) {
this.uploadVisitorLimit = requirePositive(uploadVisitorLimit, "uploadVisitorLimit");
}
public int getUploadShareLimit() {
return uploadShareLimit;
}
public void setUploadShareLimit(int uploadShareLimit) {
this.uploadShareLimit = requirePositive(uploadShareLimit, "uploadShareLimit");
}
public Duration getActiveLease() {
return activeLease;
}
public void setActiveLease(Duration activeLease) {
this.activeLease = requirePositive(activeLease, "activeLease");
}
public Duration getUploadGrantTtl() {
return uploadGrantTtl;
}
public void setUploadGrantTtl(Duration uploadGrantTtl) {
this.uploadGrantTtl = requirePositive(uploadGrantTtl, "uploadGrantTtl");
}
private static int requirePositive(int value, String name) {
if (value <= 0) {
throw new IllegalArgumentException(name + " 必须大于 0");
}
return value;
}
private static Duration requirePositive(Duration value, String name) {
if (value == null || value.isZero() || value.isNegative()) {
throw new IllegalArgumentException(name + " 必须大于 0");
}
return value;
}
}

View File

@@ -8,8 +8,8 @@ import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils; import org.springframework.util.StringUtils;
import tech.easyflow.ai.entity.Bot; import tech.easyflow.agent.entity.Agent;
import tech.easyflow.ai.service.BotService; import tech.easyflow.agent.service.AgentService;
import tech.easyflow.admin.model.dashboard.DashboardChatStatusVo; import tech.easyflow.admin.model.dashboard.DashboardChatStatusVo;
import tech.easyflow.admin.model.dashboard.DashboardAssistantTrendPointVo; import tech.easyflow.admin.model.dashboard.DashboardAssistantTrendPointVo;
import tech.easyflow.admin.model.dashboard.DashboardAssistantTrendSeriesVo; import tech.easyflow.admin.model.dashboard.DashboardAssistantTrendSeriesVo;
@@ -33,6 +33,7 @@ import tech.easyflow.common.web.exceptions.BusinessException;
import tech.easyflow.system.entity.SysAccount; import tech.easyflow.system.entity.SysAccount;
import tech.easyflow.system.entity.SysAccountRole; import tech.easyflow.system.entity.SysAccountRole;
import tech.easyflow.system.entity.SysRole; import tech.easyflow.system.entity.SysRole;
import tech.easyflow.system.enums.CategoryResourceType;
import tech.easyflow.system.service.CategoryPermissionService; import tech.easyflow.system.service.CategoryPermissionService;
import tech.easyflow.system.service.SysAccountService; import tech.easyflow.system.service.SysAccountService;
import tech.easyflow.system.service.SysAccountRoleService; import tech.easyflow.system.service.SysAccountRoleService;
@@ -81,7 +82,7 @@ public class DashboardServiceImpl implements DashboardService {
private ChatDashboardQueryService chatDashboardQueryService; private ChatDashboardQueryService chatDashboardQueryService;
@Resource @Resource
private BotService botService; private AgentService agentService;
@Resource @Resource
private CategoryPermissionService categoryPermissionService; private CategoryPermissionService categoryPermissionService;
@@ -156,7 +157,7 @@ public class DashboardServiceImpl implements DashboardService {
DashboardSummaryVo summary = new DashboardSummaryVo(); DashboardSummaryVo summary = new DashboardSummaryVo();
summary.setUserTotal(countScopedTable("tb_sys_account", "a", true, context)); summary.setUserTotal(countScopedTable("tb_sys_account", "a", true, context));
summary.setActiveUserTotal(countActiveUsers(context)); summary.setActiveUserTotal(countActiveUsers(context));
summary.setBotTotal(countScopedTable("tb_bot", "b", false, context)); summary.setAgentTotal(countScopedTable("tb_agent", "a", false, context));
summary.setWorkflowTotal(countScopedTable("tb_workflow", "w", false, context)); summary.setWorkflowTotal(countScopedTable("tb_workflow", "w", false, context));
summary.setKnowledgeBaseTotal(countScopedTable("tb_document_collection", "d", false, context)); summary.setKnowledgeBaseTotal(countScopedTable("tb_document_collection", "d", false, context));
summary.setChatMessageTotal(0L); summary.setChatMessageTotal(0L);
@@ -863,18 +864,18 @@ public class DashboardServiceImpl implements DashboardService {
if (assistantId == null) { if (assistantId == null) {
return null; return null;
} }
Bot bot = botService.getById(assistantId); Agent agent = agentService.getById(assistantId);
if (bot == null || !Integer.valueOf(1).equals(bot.getStatus())) { if (agent == null) {
throw new BusinessException("聊天助手不存在或未启用"); throw new BusinessException("智能体不存在或不可见");
} }
boolean visible = categoryPermissionService.canAccessCategory( boolean visible = categoryPermissionService.canAccessCategory(
loginAccount, loginAccount,
"BOT", CategoryResourceType.AGENT.getCode(),
bot.getCreatedBy(), agent.getCreatedBy(),
bot.getCategoryId() agent.getCategoryId()
); );
if (!visible) { if (!visible) {
throw new BusinessException("聊天助手不存在或未启用"); throw new BusinessException("智能体不存在或不可见");
} }
return assistantId; return assistantId;
} }

View File

@@ -0,0 +1,510 @@
package tech.easyflow.admin.service.system;
import com.mybatisflex.core.query.QueryWrapper;
import org.springframework.stereotype.Service;
import tech.easyflow.admin.model.SystemFormOptionsView;
import tech.easyflow.approval.entity.vo.ApprovalFlowScopeVo;
import tech.easyflow.approval.enums.ApprovalScopeType;
import tech.easyflow.agent.entity.AgentCategory;
import tech.easyflow.agent.service.AgentCategoryService;
import tech.easyflow.ai.entity.DocumentCollectionCategory;
import tech.easyflow.ai.entity.PluginCategory;
import tech.easyflow.ai.entity.ResourceCategory;
import tech.easyflow.ai.entity.WorkflowCategory;
import tech.easyflow.ai.service.DocumentCollectionCategoryService;
import tech.easyflow.ai.service.PluginCategoryService;
import tech.easyflow.ai.service.ResourceCategoryService;
import tech.easyflow.ai.service.WorkflowCategoryService;
import tech.easyflow.common.constant.enums.EnumDataStatus;
import tech.easyflow.common.entity.LoginAccount;
import tech.easyflow.common.satoken.util.SaTokenUtil;
import tech.easyflow.common.web.exceptions.BusinessException;
import tech.easyflow.system.entity.SysAccount;
import tech.easyflow.system.entity.SysDept;
import tech.easyflow.system.entity.SysMenu;
import tech.easyflow.system.entity.SysPosition;
import tech.easyflow.system.entity.SysRole;
import tech.easyflow.system.entity.vo.SysRoleCategoryScopeItemVo;
import tech.easyflow.system.service.SysDeptService;
import tech.easyflow.system.service.SysMenuService;
import tech.easyflow.system.service.SysPositionService;
import tech.easyflow.system.service.SysRoleService;
import java.math.BigInteger;
import java.util.ArrayList;
import java.util.Collection;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.function.Function;
/**
* 查询并校验系统管理表单所需的安全选项。
*/
@Service
public class SystemFormOptionService {
private final AgentCategoryService agentCategoryService;
private final WorkflowCategoryService workflowCategoryService;
private final DocumentCollectionCategoryService documentCollectionCategoryService;
private final PluginCategoryService pluginCategoryService;
private final ResourceCategoryService resourceCategoryService;
private final SysDeptService sysDeptService;
private final SysMenuService sysMenuService;
private final SysRoleService sysRoleService;
private final SysPositionService sysPositionService;
/**
* 创建系统表单选项服务。
*
* @param agentCategoryService Agent 分类服务
* @param workflowCategoryService 工作流分类服务
* @param documentCollectionCategoryService 知识库分类服务
* @param pluginCategoryService 插件分类服务
* @param resourceCategoryService 素材分类服务
* @param sysDeptService 部门服务
* @param sysMenuService 菜单服务
* @param sysRoleService 角色服务
* @param sysPositionService 岗位服务
*/
public SystemFormOptionService(
AgentCategoryService agentCategoryService,
WorkflowCategoryService workflowCategoryService,
DocumentCollectionCategoryService documentCollectionCategoryService,
PluginCategoryService pluginCategoryService,
ResourceCategoryService resourceCategoryService,
SysDeptService sysDeptService,
SysMenuService sysMenuService,
SysRoleService sysRoleService,
SysPositionService sysPositionService) {
this.agentCategoryService = agentCategoryService;
this.workflowCategoryService = workflowCategoryService;
this.documentCollectionCategoryService = documentCollectionCategoryService;
this.pluginCategoryService = pluginCategoryService;
this.resourceCategoryService = resourceCategoryService;
this.sysDeptService = sysDeptService;
this.sysMenuService = sysMenuService;
this.sysRoleService = sysRoleService;
this.sysPositionService = sysPositionService;
}
/**
* 查询审批流程资源范围选项。
*
* @return 审批流程资源范围选项
*/
public SystemFormOptionsView.ApprovalResourceScopeOptions approvalResourceScopeOptions() {
Map<String, List<SystemFormOptionsView.CategoryOption>> categories = new LinkedHashMap<>();
categories.put("AGENT", listAgentCategories());
categories.put("WORKFLOW", listWorkflowCategories());
categories.put("KNOWLEDGE", listKnowledgeCategories());
return new SystemFormOptionsView.ApprovalResourceScopeOptions(
categories,
listDepartments()
);
}
/**
* 查询角色管理表单选项。
*
* @return 角色管理表单选项
*/
public SystemFormOptionsView.RoleFormOptions roleFormOptions() {
Map<String, List<SystemFormOptionsView.CategoryOption>> categories = new LinkedHashMap<>();
categories.put("AGENT", listAgentCategories());
categories.put("PLUGIN", listPluginCategories());
categories.put("WORKFLOW", listWorkflowCategories());
categories.put("KNOWLEDGE", listKnowledgeCategories());
categories.put("RESOURCE", listResourceCategories());
return new SystemFormOptionsView.RoleFormOptions(
listMenus(),
categories
);
}
/**
* 查询账号管理表单选项。
*
* @return 账号管理表单选项
*/
public SystemFormOptionsView.AccountFormOptions accountFormOptions() {
BigInteger tenantId = requireAccount().getTenantId();
List<SystemFormOptionsView.RoleOption> roles = sysRoleService.list(QueryWrapper.create()
.eq(SysRole::getTenantId, tenantId)
.eq(SysRole::getStatus, EnumDataStatus.AVAILABLE.getCode())
.orderBy(SysRole::getId, true))
.stream()
.map(role -> new SystemFormOptionsView.RoleOption(role.getId(), role.getRoleName()))
.toList();
List<SystemFormOptionsView.PositionOption> positions = sysPositionService.list(QueryWrapper.create()
.eq(SysPosition::getTenantId, tenantId)
.eq(SysPosition::getStatus, EnumDataStatus.AVAILABLE.getCode())
.orderBy(SysPosition::getSortNo, true))
.stream()
.map(position -> new SystemFormOptionsView.PositionOption(
position.getId(), position.getPositionName()))
.toList();
return new SystemFormOptionsView.AccountFormOptions(
listDepartments(),
roles,
positions
);
}
/**
* 校验账号表单引用的部门、角色和岗位。
*
* @param account 账号表单
* @throws BusinessException 引用不存在或已禁用时抛出
*/
public void validateAccountReferences(SysAccount account) {
if (account == null) {
throw new BusinessException("账号信息不能为空");
}
if (account.getDeptId() == null) {
throw new BusinessException("部门不能为空");
}
SysDept dept = sysDeptService.getById(account.getDeptId());
BigInteger tenantId = requireAccount().getTenantId();
if (dept == null
|| !Objects.equals(dept.getTenantId(), tenantId)
|| !EnumDataStatus.AVAILABLE.getCode().equals(dept.getStatus())) {
throw new BusinessException("部门不存在或已禁用");
}
assertAvailableIds(
account.getRoleIds(),
sysRoleService::listByIds,
SysRole::getId,
SysRole::getStatus,
SysRole::getTenantId,
tenantId,
"角色"
);
if (account.getPositionIds() != null && !account.getPositionIds().isEmpty()) {
assertAvailableIds(
account.getPositionIds(),
sysPositionService::listByIds,
SysPosition::getId,
SysPosition::getStatus,
SysPosition::getTenantId,
tenantId,
"岗位"
);
}
}
/**
* 校验角色引用的菜单和部门。
*
* @param role 角色表单
* @throws BusinessException 引用不存在时抛出
*/
public void validateRoleReferences(SysRole role) {
if (role == null) {
throw new BusinessException("角色信息不能为空");
}
assertExistingIds(role.getMenuIds(), sysMenuService::listByIds, SysMenu::getId, "菜单");
if (role.getDeptIds() != null && !role.getDeptIds().isEmpty()) {
assertTenantIds(
role.getDeptIds(),
sysDeptService::listByIds,
SysDept::getId,
SysDept::getTenantId,
requireAccount().getTenantId(),
"部门");
}
}
/**
* 校验角色分类授权中提交的非 Bot 分类 ID。
*
* @param scopes 分类权限范围
* @throws BusinessException 分类类型或分类 ID 非法时抛出
*/
public void validateCategoryScopes(List<SysRoleCategoryScopeItemVo> scopes) {
if (scopes == null) {
return;
}
for (SysRoleCategoryScopeItemVo scope : scopes) {
if (scope == null || scope.getCategoryIds() == null || scope.getCategoryIds().isEmpty()) {
continue;
}
switch (String.valueOf(scope.getResourceType()).toUpperCase()) {
case "AGENT" -> assertTenantIds(
scope.getCategoryIds(),
agentCategoryService::listByIds,
AgentCategory::getId,
AgentCategory::getTenantId,
requireAccount().getTenantId(),
"Agent 分类");
case "PLUGIN" -> assertExistingIds(
scope.getCategoryIds(), pluginCategoryService::listByIds, PluginCategory::getId, "插件分类");
case "WORKFLOW" -> assertExistingIds(
scope.getCategoryIds(), workflowCategoryService::listByIds, WorkflowCategory::getId, "工作流分类");
case "KNOWLEDGE" -> assertExistingIds(
scope.getCategoryIds(),
documentCollectionCategoryService::listByIds,
DocumentCollectionCategory::getId,
"知识库分类");
case "RESOURCE" -> assertExistingIds(
scope.getCategoryIds(), resourceCategoryService::listByIds, ResourceCategory::getId, "素材分类");
case "BOT" -> throw new BusinessException("Bot 分类授权已停止维护");
default -> throw new BusinessException("不支持的分类资源类型");
}
}
}
/**
* 校验审批流程提交的分类和部门范围。
*
* @param resourceType 审批资源类型
* @param scopes 审批范围
* @throws BusinessException 范围引用不存在或资源类型不匹配时抛出
*/
public void validateApprovalScopes(String resourceType, List<ApprovalFlowScopeVo> scopes) {
if (scopes == null) {
return;
}
for (ApprovalFlowScopeVo scope : scopes) {
if (scope == null || scope.getScopeValue() == null) {
continue;
}
String scopeType = String.valueOf(scope.getScopeType()).toUpperCase();
if (ApprovalScopeType.DEPT.getCode().equals(scopeType)) {
assertTenantIds(
List.of(scope.getScopeValue()),
sysDeptService::listByIds,
SysDept::getId,
SysDept::getTenantId,
requireAccount().getTenantId(),
"部门");
continue;
}
if (!ApprovalScopeType.CATEGORY.getCode().equals(scopeType)) {
throw new BusinessException("不支持的审批范围类型");
}
switch (String.valueOf(resourceType).toUpperCase()) {
case "AGENT" -> assertTenantIds(
List.of(scope.getScopeValue()),
agentCategoryService::listByIds,
AgentCategory::getId,
AgentCategory::getTenantId,
requireAccount().getTenantId(),
"Agent 分类");
case "WORKFLOW" -> assertExistingIds(
List.of(scope.getScopeValue()),
workflowCategoryService::listByIds,
WorkflowCategory::getId,
"工作流分类");
case "KNOWLEDGE" -> assertExistingIds(
List.of(scope.getScopeValue()),
documentCollectionCategoryService::listByIds,
DocumentCollectionCategory::getId,
"知识库分类");
default -> throw new BusinessException("当前资源类型不支持分类审批范围");
}
}
}
private List<SystemFormOptionsView.DepartmentOption> listDepartments() {
BigInteger tenantId = requireAccount().getTenantId();
List<SysDept> departments = sysDeptService.list(QueryWrapper.create()
.eq(SysDept::getTenantId, tenantId)
.eq(SysDept::getStatus, EnumDataStatus.AVAILABLE.getCode())
.orderBy(SysDept::getSortNo, true));
return buildDepartmentTree(departments);
}
private List<SystemFormOptionsView.MenuOption> listMenus() {
List<SysMenu> menus = sysMenuService.list(QueryWrapper.create()
.orderBy(SysMenu::getSortNo, true));
return buildMenuTree(menus);
}
private List<SystemFormOptionsView.CategoryOption> listAgentCategories() {
BigInteger tenantId = requireAccount().getTenantId();
return agentCategoryService.list(QueryWrapper.create()
.eq(AgentCategory::getTenantId, tenantId)
.eq(AgentCategory::getStatus, EnumDataStatus.AVAILABLE.getCode())
.orderBy(AgentCategory::getSortNo, true))
.stream()
.map(category -> new SystemFormOptionsView.CategoryOption(
category.getId(), category.getCategoryName()))
.toList();
}
private List<SystemFormOptionsView.CategoryOption> listWorkflowCategories() {
return workflowCategoryService.list(QueryWrapper.create()
.orderBy(WorkflowCategory::getSortNo, true))
.stream()
.map(category -> new SystemFormOptionsView.CategoryOption(
category.getId(), category.getCategoryName()))
.toList();
}
private List<SystemFormOptionsView.CategoryOption> listKnowledgeCategories() {
return documentCollectionCategoryService.list(QueryWrapper.create()
.orderBy(DocumentCollectionCategory::getSortNo, true))
.stream()
.map(category -> new SystemFormOptionsView.CategoryOption(
category.getId(), category.getCategoryName()))
.toList();
}
private List<SystemFormOptionsView.CategoryOption> listPluginCategories() {
return pluginCategoryService.list(QueryWrapper.create()
.orderBy(PluginCategory::getId, true))
.stream()
.map(category -> new SystemFormOptionsView.CategoryOption(
category.getId(), category.getName()))
.toList();
}
private List<SystemFormOptionsView.CategoryOption> listResourceCategories() {
return resourceCategoryService.list(QueryWrapper.create()
.orderBy(ResourceCategory::getSortNo, true))
.stream()
.map(category -> new SystemFormOptionsView.CategoryOption(
category.getId(), category.getCategoryName()))
.toList();
}
private List<SystemFormOptionsView.DepartmentOption> buildDepartmentTree(List<SysDept> departments) {
Set<BigInteger> ids = new HashSet<>();
departments.forEach(item -> ids.add(item.getId()));
Map<BigInteger, List<SysDept>> children = new LinkedHashMap<>();
List<SysDept> roots = new ArrayList<>();
for (SysDept department : departments) {
BigInteger parentId = department.getParentId();
if (parentId == null || BigInteger.ZERO.equals(parentId) || !ids.contains(parentId)) {
roots.add(department);
} else {
children.computeIfAbsent(parentId, ignored -> new ArrayList<>()).add(department);
}
}
return roots.stream().map(item -> toDepartmentOption(item, children)).toList();
}
private SystemFormOptionsView.DepartmentOption toDepartmentOption(
SysDept department,
Map<BigInteger, List<SysDept>> children) {
return new SystemFormOptionsView.DepartmentOption(
department.getId(),
department.getParentId(),
department.getDeptName(),
children.getOrDefault(department.getId(), List.of()).stream()
.map(item -> toDepartmentOption(item, children))
.toList()
);
}
private List<SystemFormOptionsView.MenuOption> buildMenuTree(List<SysMenu> menus) {
Set<BigInteger> ids = new HashSet<>();
menus.forEach(item -> ids.add(item.getId()));
Map<BigInteger, List<SysMenu>> children = new LinkedHashMap<>();
List<SysMenu> roots = new ArrayList<>();
for (SysMenu menu : menus) {
BigInteger parentId = menu.getParentId();
if (parentId == null || BigInteger.ZERO.equals(parentId) || !ids.contains(parentId)) {
roots.add(menu);
} else {
children.computeIfAbsent(parentId, ignored -> new ArrayList<>()).add(menu);
}
}
return roots.stream().map(item -> toMenuOption(item, children)).toList();
}
private SystemFormOptionsView.MenuOption toMenuOption(
SysMenu menu,
Map<BigInteger, List<SysMenu>> children) {
return new SystemFormOptionsView.MenuOption(
menu.getId(),
menu.getParentId(),
menu.getMenuTitle(),
children.getOrDefault(menu.getId(), List.of()).stream()
.map(item -> toMenuOption(item, children))
.toList()
);
}
private <T> void assertAvailableIds(
Collection<BigInteger> rawIds,
Function<Collection<BigInteger>, List<T>> loader,
Function<T, BigInteger> idGetter,
Function<T, Integer> statusGetter,
Function<T, BigInteger> tenantGetter,
BigInteger tenantId,
String label) {
Set<BigInteger> ids = normalizeIds(rawIds);
if (ids.isEmpty()) {
throw new BusinessException(label + "不能为空");
}
List<T> records = loader.apply(ids);
boolean valid = records.size() == ids.size()
&& records.stream().allMatch(item ->
ids.contains(idGetter.apply(item))
&& EnumDataStatus.AVAILABLE.getCode().equals(statusGetter.apply(item))
&& Objects.equals(tenantGetter.apply(item), tenantId));
if (!valid) {
throw new BusinessException(label + "不存在或已禁用");
}
}
private <T> void assertExistingIds(
Collection<BigInteger> rawIds,
Function<Collection<BigInteger>, List<T>> loader,
Function<T, BigInteger> idGetter,
String label) {
Set<BigInteger> ids = normalizeIds(rawIds);
if (ids.isEmpty()) {
return;
}
List<T> records = loader.apply(ids);
boolean valid = records.size() == ids.size()
&& records.stream().allMatch(item -> ids.contains(idGetter.apply(item)));
if (!valid) {
throw new BusinessException(label + "不存在或无权访问");
}
}
private <T> void assertTenantIds(
Collection<BigInteger> rawIds,
Function<Collection<BigInteger>, List<T>> loader,
Function<T, BigInteger> idGetter,
Function<T, BigInteger> tenantGetter,
BigInteger tenantId,
String label) {
Set<BigInteger> ids = normalizeIds(rawIds);
if (ids.isEmpty()) {
return;
}
List<T> records = loader.apply(ids);
boolean valid = records.size() == ids.size()
&& records.stream().allMatch(item ->
ids.contains(idGetter.apply(item))
&& Objects.equals(tenantGetter.apply(item), tenantId));
if (!valid) {
throw new BusinessException(label + "不存在或无权访问");
}
}
private LoginAccount requireAccount() {
LoginAccount account = SaTokenUtil.getLoginAccount();
if (account == null || account.getTenantId() == null) {
throw new BusinessException("当前登录状态失效,请重新登录后再试");
}
return account;
}
private Set<BigInteger> normalizeIds(Collection<BigInteger> rawIds) {
Set<BigInteger> ids = new LinkedHashSet<>();
if (rawIds != null) {
rawIds.stream().filter(Objects::nonNull).forEach(ids::add);
}
return ids;
}
}

View File

@@ -0,0 +1,103 @@
package tech.easyflow.admin.controller;
import cn.dev33.satoken.annotation.SaCheckPermission;
import org.testng.Assert;
import org.testng.annotations.Test;
import tech.easyflow.admin.controller.agent.AgentSessionController;
import tech.easyflow.admin.controller.ai.ChatHistoryController;
import tech.easyflow.admin.controller.ai.ModelController;
import tech.easyflow.admin.controller.ai.WorkFlowNodeController;
import tech.easyflow.admin.controller.ai.WorkflowController;
import tech.easyflow.admin.controller.dashboard.DashboardController;
import tech.easyflow.admin.controller.job.SysJobController;
import tech.easyflow.admin.controller.system.ApprovalFlowController;
import tech.easyflow.admin.controller.system.SysAccountController;
import tech.easyflow.admin.controller.system.SysRoleController;
import java.lang.reflect.Method;
import java.util.Arrays;
/**
* 管理端页面能力接口权限归属契约测试。
*/
public class PermissionIsolationContractTest {
/**
* 验证工作流设计器依赖的选项接口只要求工作流查询权限。
*/
@Test
public void workflowDesignerOptionsBelongToWorkflowPermission() {
assertMethodPermission(
WorkflowController.class,
"designerOptions",
"/api/v1/workflow/query"
);
assertMethodPermission(
WorkflowController.class,
"designerChildWorkflow",
"/api/v1/workflow/query"
);
assertMethodPermission(
WorkFlowNodeController.class,
"getChainParams",
"/api/v1/workflow/query"
);
}
/**
* 验证各管理页面的辅助能力接口使用页面自身权限。
*/
@Test
public void pageOptionsBelongToOwningPagePermissions() {
assertMethodPermission(ModelController.class, "gatewayConfig", "/api/v1/model/query");
assertMethodPermission(DashboardController.class, "agentOptions", "/api/v1/dashboard/query");
assertMethodPermission(SysJobController.class, "workflowOptions", "/api/v1/sysJob/save");
assertMethodPermission(SysJobController.class, "getNextTimes", "/api/v1/sysJob/save");
assertMethodPermission(ApprovalFlowController.class, "resourceScopeOptions", "/api/v1/approvalFlow/save");
assertMethodPermission(SysRoleController.class, "formOptions", "/api/v1/sysRole/query");
assertMethodPermission(SysAccountController.class, "formOptions", "/api/v1/sysAccount/save");
}
/**
* 验证 Agent 会话和聊天历史接口分别使用各自页面权限。
*/
@Test
public void agentSessionAndHistoryUseIndependentPermissions() {
assertClassPermission(AgentSessionController.class, "/api/v1/agent/session/query");
assertClassPermission(ChatHistoryController.class, "/api/v1/chatHistory/query");
}
/**
* 断言控制器方法只声明指定权限。
*
* @param controllerType 控制器类型
* @param methodName 方法名
* @param expectedPermission 期望权限
*/
private void assertMethodPermission(
Class<?> controllerType,
String methodName,
String expectedPermission) {
Method method = Arrays.stream(controllerType.getDeclaredMethods())
.filter(candidate -> methodName.equals(candidate.getName()))
.findFirst()
.orElseThrow(() -> new AssertionError("未找到控制器方法:" + methodName));
SaCheckPermission permission = method.getAnnotation(SaCheckPermission.class);
Assert.assertNotNull(permission, methodName + " 缺少权限注解");
Assert.assertEquals(permission.value(), new String[]{expectedPermission});
}
/**
* 断言控制器类只声明指定权限。
*
* @param controllerType 控制器类型
* @param expectedPermission 期望权限
*/
private void assertClassPermission(Class<?> controllerType, String expectedPermission) {
SaCheckPermission permission = controllerType.getAnnotation(SaCheckPermission.class);
Assert.assertNotNull(permission, controllerType.getSimpleName() + " 缺少权限注解");
Assert.assertEquals(permission.value(), new String[]{expectedPermission});
}
}

View File

@@ -0,0 +1,77 @@
package tech.easyflow.admin.controller.agent;
import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.JSONObject;
import org.testng.Assert;
import org.testng.annotations.Test;
import tech.easyflow.common.domain.Result;
import tech.easyflow.common.web.jsonbody.JsonBody;
import tech.easyflow.common.web.jsonbody.JsonBodyParser;
import java.lang.reflect.Method;
import java.lang.reflect.Modifier;
import java.lang.reflect.ParameterizedType;
import java.math.BigInteger;
import java.util.Arrays;
import java.util.List;
/**
* Agent Skill 绑定管理端 API 安全契约测试。
*/
public class AgentSkillBindingContractTest {
/**
* 验证请求 DTO 仅暴露 Agent ID、Skill ID 与排序号。
*
* @throws Exception 反序列化失败
*/
@Test
public void requestUsesWhitelistFieldsAndDropsServerSnapshot() throws Exception {
JSONObject json = JSON.parseObject("""
{
"agentId": 10,
"bindings": [{
"skillId": 101,
"sortNo": 2,
"resourceSnapshot": {"skillContent": "forged"},
"resourceSummary": {"displayName": "forged"}
}]
}
""");
AgentSkillBindingUpdateRequest request = (AgentSkillBindingUpdateRequest) JsonBodyParser.parseJsonBody(
json, AgentSkillBindingUpdateRequest.class, AgentSkillBindingUpdateRequest.class, "");
Assert.assertEquals(request.getAgentId(), BigInteger.TEN);
Assert.assertEquals(request.getBindings().get(0).getSkillId(), BigInteger.valueOf(101));
Assert.assertEquals(request.getBindings().get(0).getSortNo(), Integer.valueOf(2));
Assert.assertTrue(request.getBindings().get(0).toEntity().getResourceSnapshot().isEmpty());
Assert.assertTrue(request.getBindings().get(0).toEntity().getResourceSummary().isEmpty());
Assert.assertEquals(
Arrays.stream(AgentSkillBindingUpdateRequest.Binding.class.getDeclaredFields())
.filter(field -> !Modifier.isStatic(field.getModifiers()))
.map(field -> field.getName()).toList(),
List.of("skillId", "sortNo"));
}
/**
* 验证更新入口使用白名单 DTO 并返回脱敏视图。
*
* @throws Exception 反射失败
*/
@Test
public void updateEndpointReturnsSafeSkillBindingViews() throws Exception {
Method method = AgentController.class.getMethod(
"updateSkillBinding", AgentSkillBindingUpdateRequest.class);
JsonBody jsonBody = method.getParameters()[0].getAnnotation(JsonBody.class);
ParameterizedType resultType = (ParameterizedType) method.getGenericReturnType();
ParameterizedType listType = (ParameterizedType) resultType.getActualTypeArguments()[0];
Assert.assertNotNull(jsonBody);
Assert.assertEquals(resultType.getRawType(), Result.class);
Assert.assertEquals(listType.getRawType(), List.class);
Assert.assertEquals(listType.getActualTypeArguments()[0], AgentDetailView.SkillBindingView.class);
Assert.assertFalse(Arrays.stream(AgentDetailView.SkillBindingView.class.getRecordComponents())
.anyMatch(component -> "resourceSnapshot".equals(component.getName())));
}
}

View File

@@ -6,8 +6,9 @@ import org.testng.Assert;
import org.testng.annotations.BeforeMethod; import org.testng.annotations.BeforeMethod;
import org.testng.annotations.Test; import org.testng.annotations.Test;
import tech.easyflow.admin.controller.ai.support.AiResourceCreatorNameSupport; import tech.easyflow.admin.controller.ai.support.AiResourceCreatorNameSupport;
import tech.easyflow.admin.controller.ai.support.BotResourceCreatorNameSupport;
import tech.easyflow.ai.entity.Bot; import tech.easyflow.ai.entity.Bot;
import tech.easyflow.ai.service.AiResourceApprovalStateService; import tech.easyflow.ai.service.BotApprovalStateService;
import tech.easyflow.ai.service.BotDocumentCollectionService; import tech.easyflow.ai.service.BotDocumentCollectionService;
import tech.easyflow.ai.service.BotMessageService; import tech.easyflow.ai.service.BotMessageService;
import tech.easyflow.ai.service.BotService; import tech.easyflow.ai.service.BotService;
@@ -38,7 +39,7 @@ public class BotControllerTest {
private BotDocumentCollectionService botDocumentCollectionService; private BotDocumentCollectionService botDocumentCollectionService;
private BotMessageService botMessageService; private BotMessageService botMessageService;
private CategoryPermissionService categoryPermissionService; private CategoryPermissionService categoryPermissionService;
private AiResourceApprovalStateService aiResourceApprovalStateService; private BotApprovalStateService botApprovalStateService;
private SysAccountService sysAccountService; private SysAccountService sysAccountService;
/** /**
@@ -52,7 +53,7 @@ public class BotControllerTest {
botDocumentCollectionService = mock(BotDocumentCollectionService.class); botDocumentCollectionService = mock(BotDocumentCollectionService.class);
botMessageService = mock(BotMessageService.class); botMessageService = mock(BotMessageService.class);
categoryPermissionService = mock(CategoryPermissionService.class); categoryPermissionService = mock(CategoryPermissionService.class);
aiResourceApprovalStateService = mock(AiResourceApprovalStateService.class); botApprovalStateService = mock(BotApprovalStateService.class);
sysAccountService = mock(SysAccountService.class); sysAccountService = mock(SysAccountService.class);
} }
@@ -69,10 +70,12 @@ public class BotControllerTest {
botMessageService botMessageService
); );
AiResourceCreatorNameSupport creatorNameSupport = new AiResourceCreatorNameSupport(); AiResourceCreatorNameSupport creatorNameSupport = new AiResourceCreatorNameSupport();
BotResourceCreatorNameSupport botCreatorNameSupport =
new BotResourceCreatorNameSupport(creatorNameSupport);
setField(creatorNameSupport, "sysAccountService", sysAccountService); setField(creatorNameSupport, "sysAccountService", sysAccountService);
setField(controller, "categoryPermissionService", categoryPermissionService); setField(controller, "categoryPermissionService", categoryPermissionService);
setField(controller, "aiResourceApprovalStateService", aiResourceApprovalStateService); setField(controller, "botApprovalStateService", botApprovalStateService);
setField(controller, "aiResourceCreatorNameSupport", creatorNameSupport); setField(controller, "botResourceCreatorNameSupport", botCreatorNameSupport);
Bot bot = new Bot(); Bot bot = new Bot();
bot.setId(BigInteger.valueOf(101)); bot.setId(BigInteger.valueOf(101));
@@ -84,7 +87,7 @@ public class BotControllerTest {
when(botService.page(any(Page.class), any(QueryWrapper.class))).thenReturn(page); when(botService.page(any(Page.class), any(QueryWrapper.class))).thenReturn(page);
when(sysAccountService.resolveDisplayNameMap(Collections.singleton(BigInteger.valueOf(7)))) when(sysAccountService.resolveDisplayNameMap(Collections.singleton(BigInteger.valueOf(7))))
.thenReturn(Map.of(BigInteger.valueOf(7), "管理员")); .thenReturn(Map.of(BigInteger.valueOf(7), "管理员"));
doNothing().when(aiResourceApprovalStateService).fillBotApprovalState(page.getRecords()); doNothing().when(botApprovalStateService).fillApprovalState(page.getRecords());
Page<Bot> result = controller.invokeQueryPage(new Page<>(1, 10), QueryWrapper.create()); Page<Bot> result = controller.invokeQueryPage(new Page<>(1, 10), QueryWrapper.create());

View File

@@ -0,0 +1,85 @@
package tech.easyflow.admin.controller.ai;
import org.mockito.MockedStatic;
import org.testng.annotations.Test;
import tech.easyflow.chatlog.domain.dto.ChatSessionPage;
import tech.easyflow.chatlog.domain.dto.ChatSessionSummary;
import tech.easyflow.chatlog.domain.query.ChatSessionFilterQuery;
import tech.easyflow.chatlog.service.ChatHistoryManageService;
import tech.easyflow.agent.service.AgentOptionQueryService;
import tech.easyflow.common.entity.LoginAccount;
import tech.easyflow.common.satoken.util.SaTokenUtil;
import tech.easyflow.system.service.CategoryPermissionService;
import java.math.BigInteger;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* {@link ChatHistoryController} 数据范围测试。
*/
public class ChatHistoryControllerTest {
/**
* 验证普通账号查询时将本人范围传给服务层。
*/
@Test
public void listSessionsShouldUseCurrentUserScopeForRegularAccount() {
BigInteger accountId = BigInteger.valueOf(20);
ChatHistoryManageService service = mock(ChatHistoryManageService.class);
CategoryPermissionService permissionService = mock(CategoryPermissionService.class);
ChatHistoryController controller = new ChatHistoryController(
service, permissionService, mock(AgentOptionQueryService.class));
ChatSessionFilterQuery query = new ChatSessionFilterQuery();
LoginAccount account = loginAccount(accountId);
when(permissionService.isSuperAdmin(account)).thenReturn(false);
when(service.queryAdminSessions(accountId, false, query)).thenReturn(new ChatSessionPage());
try (MockedStatic<SaTokenUtil> saToken = mockStatic(SaTokenUtil.class)) {
saToken.when(SaTokenUtil::getLoginAccount).thenReturn(account);
controller.listSessions(query);
}
verify(service).queryAdminSessions(accountId, false, query);
}
/**
* 验证超级管理员查询详情时保留全量范围。
*/
@Test
public void getSessionShouldUseAllScopeForSuperAdmin() {
BigInteger accountId = BigInteger.ONE;
BigInteger sessionId = BigInteger.valueOf(30);
ChatHistoryManageService service = mock(ChatHistoryManageService.class);
CategoryPermissionService permissionService = mock(CategoryPermissionService.class);
ChatHistoryController controller = new ChatHistoryController(
service, permissionService, mock(AgentOptionQueryService.class));
LoginAccount account = loginAccount(accountId);
when(permissionService.isSuperAdmin(account)).thenReturn(true);
when(service.getAdminSession(accountId, true, sessionId)).thenReturn(new ChatSessionSummary());
try (MockedStatic<SaTokenUtil> saToken = mockStatic(SaTokenUtil.class)) {
saToken.when(SaTokenUtil::getLoginAccount).thenReturn(account);
controller.getSession(sessionId);
}
verify(service).getAdminSession(accountId, true, sessionId);
}
/**
* 构造登录账号。
*
* @param accountId 账号 ID
* @return 登录账号
*/
private LoginAccount loginAccount(BigInteger accountId) {
LoginAccount account = new LoginAccount();
account.setId(accountId);
return account;
}
}

View File

@@ -0,0 +1,120 @@
package tech.easyflow.admin.controller.ai;
import org.testng.Assert;
import org.testng.annotations.Test;
import org.springframework.web.bind.annotation.PostMapping;
import tech.easyflow.ai.dto.DocumentChunkContentUpdateRequest;
import tech.easyflow.ai.entity.DocumentChunk;
import tech.easyflow.ai.service.DocumentChunkService;
import tech.easyflow.common.web.controller.BaseController;
import tech.easyflow.common.web.jsonbody.JsonBody;
import tech.easyflow.system.permission.resource.RequireResourceAccess;
import java.lang.reflect.Field;
import java.lang.reflect.Method;
import java.lang.reflect.Parameter;
import java.math.BigInteger;
import java.util.Arrays;
import java.util.Set;
import java.util.stream.Collectors;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* 文档分块维护接口契约测试。
*/
public class DocumentChunkControllerContractTest {
@Test
public void updateEndpointsShouldOnlyAcceptChunkIdAndContent() throws Exception {
Method adminUpdate = DocumentChunkController.class.getDeclaredMethod(
"update",
DocumentChunkContentUpdateRequest.class
);
Method shareUpdate = ShareKnowledgeController.class.getDeclaredMethod(
"updateDocumentChunk",
String.class,
DocumentChunkContentUpdateRequest.class
);
Assert.assertNotNull(adminUpdate);
Assert.assertNotNull(shareUpdate);
assertStrictJsonBody(adminUpdate.getParameters()[0]);
assertStrictJsonBody(shareUpdate.getParameters()[1]);
Assert.assertEquals(
adminUpdate.getAnnotation(RequireResourceAccess.class).idExpr(),
"#request.id"
);
Set<String> fields = Arrays.stream(
DocumentChunkContentUpdateRequest.class.getDeclaredFields()
)
.map(Field::getName)
.collect(Collectors.toSet());
Assert.assertEquals(Set.of("id", "content"), fields);
Assert.assertEquals(
DocumentChunkController.class.getMethod(
"update",
DocumentChunkContentUpdateRequest.class
).getDeclaringClass(),
DocumentChunkController.class
);
Assert.assertEquals(
Arrays.stream(DocumentChunkController.class.getDeclaredMethods())
.filter(method -> method.getName().equals("update"))
.filter(method -> !method.isBridge() && !method.isSynthetic())
.count(),
1L
);
}
@Test
public void controllerShouldNotExposeGenericWriteEndpoints() {
Assert.assertEquals(DocumentChunkController.class.getSuperclass(), BaseController.class);
Set<String> postMappings = Arrays.stream(DocumentChunkController.class.getMethods())
.map(method -> method.getAnnotation(PostMapping.class))
.filter(annotation -> annotation != null)
.flatMap(annotation -> Arrays.stream(annotation.value()))
.collect(Collectors.toSet());
Assert.assertEquals(
postMappings,
Set.of("update", "removeChunk", "syncStatus", "retrySync")
);
Assert.assertFalse(postMappings.contains("save"));
Assert.assertFalse(postMappings.contains("remove"));
Assert.assertFalse(postMappings.contains("removeBatch"));
}
@Test
public void adminUpdateAndDeleteShouldUseUnifiedMaintenanceService() {
DocumentChunkService service = mock(DocumentChunkService.class);
DocumentChunkController controller = new DocumentChunkController(service);
DocumentChunk current = new DocumentChunk();
current.setId(BigInteger.ONE);
current.setDocumentCollectionId(BigInteger.TWO);
when(service.getById(BigInteger.ONE)).thenReturn(current);
DocumentChunkContentUpdateRequest request = new DocumentChunkContentUpdateRequest();
request.setId(BigInteger.ONE);
request.setContent("updated");
controller.update(request);
controller.removeChunk(BigInteger.ONE);
verify(service).updateContent(BigInteger.TWO, BigInteger.ONE, "updated");
verify(service).deleteChunk(BigInteger.TWO, BigInteger.ONE);
verify(service, never()).updateById(any(DocumentChunk.class));
verify(service, never()).removeById(any());
}
private static void assertStrictJsonBody(Parameter parameter) {
JsonBody jsonBody = parameter.getAnnotation(JsonBody.class);
Assert.assertNotNull(jsonBody);
Assert.assertTrue(jsonBody.required());
Assert.assertFalse(jsonBody.skipConvertError());
}
}

View File

@@ -0,0 +1,71 @@
package tech.easyflow.admin.controller.ai;
import com.mybatisflex.core.query.QueryWrapper;
import org.mockito.ArgumentCaptor;
import org.mockito.MockedStatic;
import org.testng.Assert;
import org.testng.annotations.Test;
import tech.easyflow.ai.entity.Mcp;
import tech.easyflow.ai.service.AgentResourceReferenceService;
import tech.easyflow.ai.service.McpService;
import tech.easyflow.common.entity.LoginAccount;
import tech.easyflow.common.satoken.util.SaTokenUtil;
import java.math.BigInteger;
import java.util.Locale;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* {@link McpController} 删除锁测试。
*/
public class McpControllerTest {
/**
* 验证 MCP 删除先锁定资源行,再执行删除。
*/
@Test
public void removeShouldLockMcpBeforeRemoval() {
McpService mcpService = mock(McpService.class);
AgentResourceReferenceService referenceService = mock(AgentResourceReferenceService.class);
when(mcpService.getOne(any(QueryWrapper.class))).thenReturn(new Mcp());
McpController controller = new McpController(mcpService);
setField(controller, "agentResourceReferenceService", referenceService);
LoginAccount loginAccount = new LoginAccount();
loginAccount.setTenantId(BigInteger.ONE);
try (MockedStatic<SaTokenUtil> login = mockStatic(SaTokenUtil.class)) {
login.when(SaTokenUtil::getLoginAccount).thenReturn(loginAccount);
controller.remove(BigInteger.TEN);
}
ArgumentCaptor<QueryWrapper> queryCaptor = ArgumentCaptor.forClass(QueryWrapper.class);
verify(mcpService).getOne(queryCaptor.capture());
Assert.assertTrue(
queryCaptor.getValue().toSQL().toUpperCase(Locale.ROOT).contains("FOR UPDATE")
);
verify(referenceService).assertMcpUnused(BigInteger.TEN);
verify(mcpService).removeMcp(BigInteger.TEN);
}
/**
* 通过反射设置字段值。
*
* @param target 目标对象
* @param fieldName 字段名
* @param value 字段值
*/
private static void setField(Object target, String fieldName, Object value) {
try {
java.lang.reflect.Field field = target.getClass().getDeclaredField(fieldName);
field.setAccessible(true);
field.set(target, value);
} catch (ReflectiveOperationException e) {
throw new IllegalStateException("设置测试字段失败: " + fieldName, e);
}
}
}

View File

@@ -0,0 +1,65 @@
package tech.easyflow.admin.controller.ai;
import com.mybatisflex.core.query.QueryWrapper;
import org.testng.Assert;
import org.testng.annotations.Test;
import tech.easyflow.ai.entity.Model;
import tech.easyflow.ai.mapper.ModelMapper;
import tech.easyflow.ai.service.ModelService;
import tech.easyflow.common.domain.Result;
import java.math.BigInteger;
import java.util.List;
import java.util.Map;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* 模型列表控制器测试。
*/
public class ModelControllerTest {
/**
* 验证模型列表使用包含目录能力元数据的服务查询。
*/
@Test
public void listShouldReturnCapabilityDecoratedModels() {
ModelService service = mock(ModelService.class);
Model query = new Model();
Model decoratedModel = new Model();
decoratedModel.setContextWindowTokens(1_000_000L);
decoratedModel.setMaxOutputTokens(64_000L);
when(service.listSelectableModels(query, false, "id", "desc"))
.thenReturn(List.of(decoratedModel));
ModelController controller = new ModelController(service);
Result<List<Model>> result = controller.list(query, false, "id", "desc");
Assert.assertSame(decoratedModel, result.getData().get(0));
Assert.assertEquals(Long.valueOf(1_000_000L),
result.getData().get(0).getContextWindowTokens());
verify(service).listSelectableModels(query, false, "id", "desc");
}
/**
* 验证仅包含空白字符的选择文本不会生成模糊查询条件。
*/
@Test
public void selectLlmShouldIgnoreBlankSearchText() {
ModelService service = mock(ModelService.class);
ModelMapper mapper = mock(ModelMapper.class);
when(service.getMapper()).thenReturn(mapper);
when(mapper.selectListWithRelationsByQuery(any(QueryWrapper.class)))
.thenReturn(List.of());
ModelController controller = new ModelController(service);
Result<Map<String, List<Model>>> result = controller.selectLlmByProviderAndModelType(
"chat", BigInteger.ONE, " ");
Assert.assertTrue(result.getData().isEmpty());
verify(mapper).selectListWithRelationsByQuery(any(QueryWrapper.class));
}
}

View File

@@ -0,0 +1,30 @@
package tech.easyflow.admin.controller.ai;
import org.testng.Assert;
import org.testng.annotations.Test;
import tech.easyflow.ai.service.PluginService;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* {@link PluginController} 删除接口测试。
*/
public class PluginControllerTest {
/**
* 插件删除接口必须委托事务服务执行完整引用校验和删除。
*/
@Test
public void removeShouldDelegateToTransactionalService() {
PluginService pluginService = mock(PluginService.class);
when(pluginService.removePlugin("10")).thenReturn(true);
PluginController controller = new PluginController(pluginService);
boolean removed = controller.removePlugin("10").getData();
Assert.assertTrue(removed);
verify(pluginService).removePlugin("10");
}
}

Some files were not shown because too many files have changed in this diff Show More