Compare commits

249 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
6c3d98eaac feat: license校验方式增加 2026-05-18 10:03:19 +08:00
b7f3ae2854 fix: 修复管理端类型检查错误
- 为分页数据列表补充明确行类型

- 修正插件数组默认值解析的类型转换

- 删除 tinyflow 未使用辅助函数
2026-05-18 10:00:41 +08:00
2907acac95 feat: 默认选择首个聊天智能体
- 拉取智能体列表后在无会话上下文时复用现有切换逻辑选择第一个智能体

- 保留历史会话和已选智能体的优先级
2026-05-18 10:00:25 +08:00
0947009ee6 feat: 统一聊天 Markdown 渲染
- 新增 ChatTimeMarkdown 并在管理端、用户端聊天入口复用

- 收敛聊天页面重复 Markdown 样式,保留工具和思考块独立渲染
2026-05-18 09:59:59 +08:00
a186066641 调整菜单顺序 2026-05-14 21:31:35 +08:00
1a6ea64e80 feat: 支持聊天多版本答案切换
- 为管理端、公共聊天和用户中心补充回答变体查询与切换能力

- 支持基于指定轮次重新生成并同步前后端多版本状态

- 保留 application.yml 与本地截图文件为未提交状态
2026-05-14 21:23:20 +08:00
da58077d59 feat: 完成工作流 Public API 授权闭环
- 新增访问令牌工作流 API 全局授权与 Public Workflow API 权限断言

- 补齐 API Key 执行记录归属、状态查询与下线后不可恢复边界

- 增加管理端接口调用说明与访问令牌授权开关
2026-05-14 20:41:34 +08:00
47c2bad839 feat: 完成管理端聊天工作台收口
- 新增管理端聊天工作台与会话级额外知识库持久化

- 补齐发布态聊天、历史会话只读判断与答案版本切换

- 新增 chat_round 热数据与主线消息读取支撑
2026-05-14 20:22:46 +08:00
2ad8935a61 chore: 清理根目录废弃协议与配置文件
- 删除未再使用的聊天协议草稿与许可证说明文档

- 移除根目录废弃的 lefthook 模板配置,保留子工作区自有配置
2026-05-11 21:26:34 +08:00
21b1bc82f6 feat: 增加工作流英文名格式校验
- 在工作流弹窗中要求 englishName 必填且只允许字母数字下划线和中划线
2026-05-11 21:26:00 +08:00
4a15124183 feat: 重构聊天时间线与附件上传交互
- 管理端和用户中心统一切换到 chatTime 时间线模型,按真实 assistant/tool 时序渲染

- 收紧工具气泡与 JSON 展示样式,保留同气泡内的工具状态更新

- 回形针直接触发文件选择,附件列表上移到输入框上方并补充共享 helper 测试
2026-05-11 21:25:21 +08:00
e27834ee0c fix: 统一适配 DeepSeek 工具与思考历史回传
- 结构化持久化 assistant/tool 历史,恢复真实消息链回放

- 为 DeepSeek 显式开启 thinking 协议配置,并补齐 public-api 与工具英文名兜底

- 增加聊天历史回放、tool 名称与请求参数解析相关测试
2026-05-11 21:24:20 +08:00
c1590b0d8a feat: 收口聊天时知识库工具可见性
- 新增 chatTime 工具可见性抽象与知识库 resolver

- 聊天装配链路按当前用户过滤知识库工具并补齐调用兜底

- 补充聊天时显式登录快照与对应后端测试
2026-05-11 20:54:13 +08:00
ff863e3c27 feat: 增加 EasyFlow license 启动校验能力
- 新增 license 解析、验签、机器指纹校验与失败日志分流

- 支持开发态 classpath 读取与打包排除 lic 资源
2026-05-10 17:07:24 +08:00
516d43ce7d feat: 增强管理端工作台用户活跃榜能力
- 新增用户活跃榜接口、筛选与导出能力

- 支持按智能体过滤排行榜并补充前后端测试
2026-05-10 17:06:22 +08:00
8d07b306e5 build: 清理 tinyflow-ui 构建告警
- 收口 Svelte props 响应式引用与 custom element props 声明

- 清理局部未使用样式与类型告警并保持现有业务语义
2026-05-06 19:22:33 +08:00
ba70fec9a5 fix: 优化公开聊天页登录态恢复与鉴权引导
- 支持复用现有登录态并恢复 refresh token

- 未认证访问时补充跳转登录提示与引导文案
2026-05-06 19:22:21 +08:00
31b0e21d3d feat: 增强工作台趋势概览与聊天排行
- 支持用户活跃与智能体活跃趋势统计及自定义时间范围

- 增加用户活跃榜与智能体趋势数据结构及查询实现

- 同步补齐工作台页面展示与定向测试
2026-05-06 19:22:09 +08:00
5827ecde42 feat: 增强管理员工作台聊天统计看板
- 工作台接入聊天消息总数、会话总数、活跃智能体数、趋势与 Top5 排行

- dashboard 接口新增 chatStatus 与聊天统计字段,分析库不可用时明确降级

- today 维度按小时聚合聊天趋势,并补充后端查询与测试覆盖
2026-04-19 17:40:01 +08:00
1d8b9d9662 feat: 完成工作流多文件文档解析闭环
- 支持文档解析节点批量解析并收口为 documents 轻量输出

- 收口引用树、节点输出展示与旧工作流固定输出兼容

- 修复共享按钮点击事件,恢复多个节点加号交互
2026-04-19 16:12:36 +08:00
a5aab86de2 feat: 支持工作流文件节点多格式文档导出
- 补齐 md/html/pdf/docx 导出与统一渲染服务

- 收口文件生成节点配置与格式校验

- 修复 PDF 中文字体与 Markdown 渲染链路
2026-04-19 15:23:23 +08:00
51198ff492 fix: 修复开始表单旧工作流兼容回退
- 避免旧开始节点缺失 user_input 时运行态强行注入系统问题字段

- 补充运行参数解析兼容测试并收口 M12 尾项
2026-04-19 14:11:07 +08:00
9feb889637 feat: 完成工作流开始节点开场表单
- 增加开始节点 startFormMeta/startFormSchema 配置与运行参数解析

- 统一 Admin/UserCenter 开场表单渲染与文件集合输入

- 补充开始表单校验、引用迁移和前端工具测试
2026-04-19 13:57:57 +08:00
8546d927bc feat: 支持工作流文档节点解析 Office 新格式
- DocNode 对 pdf/docx/pptx/xlsx 统一走桥接同步解析

- 修复 DOCX 误回退到 PDF 解析服务的问题并补齐回归测试
2026-04-18 19:52:45 +08:00
4130381658 feat: 支持知识库导入 PPTX 与 XLSX 文档
- 打通 Office 文档桥接解析、解析进度承接与图片引用改写

- 落地 PPTX 按页分块、XLSX 行窗口分块以及预览与检索渲染闭环
2026-04-18 13:01:17 +08:00
ad67ba85ad fix: 适配文档解析实体重构
- 将文档桥接层切换到 easy-agents document entity 包

- 清理请求工厂中已移除的 PDF 专属字段调用

- 更新桥接映射与测试以保持知识库 PDF 解析链路可用
2026-04-16 21:51:16 +08:00
2689adfa40 feat: 重构知识库文档导入任务化流程
- 新增上传建单、异步解析、分块处理与异步向量化闭环

- 收口分享页权限、完成态检索过滤与 SSE 局部状态刷新
2026-04-15 19:27:22 +08:00
a41b50959e feat: 完成S02桥接能力并接通M09工作流文档解析闭环
- 新增统一文档解析桥接子域,封装 easy-agents 文档解析门面

- 支持工作流开始节点文件上传与素材选择的单文件对象输入

- DocNode 改为文档解析节点,PDF 走统一解析,非 PDF 保持默认读取
2026-04-14 19:57:32 +08:00
855e93ecbf feat: 展示 AI 资源创建人信息
- 为 Bot、工作流、知识库、插件列表补充创建人名称回填

- 在卡片中展示创建者与创建时间

- 补充后端与前端对应测试
2026-04-13 14:58:14 +08:00
ae10383f17 fix: 补齐分享页分页请求客户端
- 让 PageData 支持注入自定义 requestClient

- 修复知识库分享页列表请求仍走默认 api 的问题
2026-04-13 14:55:11 +08:00
31a755a8bc feat: 收口知识库分享链路
- 新增 shareKey 单参数 URL 分享页与失效页

- 新增知识库分享后端鉴权、审计与迁移脚本

- 在访问令牌中增加知识库分享授权入口
2026-04-13 14:44:31 +08:00
8cfe5400fe feat: 优化工作流字段化参数配置
- 开始节点固定 user_input 并区分系统入口与自定义参数

- LLM 与知识库节点切换为字段值加上游引用配置

- 单节点调试改为字段预览与上游引用输入模式
2026-04-12 20:31:02 +08:00
47655a728b feat: 支持工作流插件复用与试运行
- 新增工作流插件类型、发布快照同步、实时可用性与下线影响检查

- 收口绑定候选、分类权限、间接环路校验与运行态优雅降级

- 补齐管理端工作流插件配置、详情与试运行界面及定向测试
2026-04-12 13:15:13 +08:00
6da90e2296 chore: 调整开发环境默认检索引擎
- 将 rag.engine 默认值切换为 lucene

- 补充知识检索相关配置注释
2026-04-09 17:27:01 +08:00
cfbeaf11fe feat: 优化用户导入失败反馈与模板说明
- 支持按字段返回用户导入失败明细

- 补充导入模板填写说明与名称匹配规则

- 优化管理端导入失败弹窗与错误展示
2026-04-09 17:25:50 +08:00
4e565aef99 feat: 收敛AI资源发布审批生命周期
- 统一工作流、知识库、聊天助手的发布、重新发布、下线与删除链路

- 收敛审批编排、生命周期状态机与展示态,补齐审批管理和快照预览

- 调整审批管理权限模型为单入口页面加内部按钮权限
2026-04-09 17:13:54 +08:00
81125ce55c fix: 修复chatlog会话元数据回写与覆盖问题
- 为消息追加命令补充用户、助手与会话标题元信息

- 持久化后按会话聚合触发createOrTouch并优化空值覆盖策略

- 增加ChatPersistMySqlApplyService单测覆盖会话回写链路
2026-04-07 14:51:11 +08:00
3f128e977a feat: 归档L03与L09审批发布能力
- 新增统一审批中心与审批管理页面,支持流程配置、审批详情与角色/用户审批对象

- 接入聊天助手、知识库、工作流的发布与删除审批,并补齐发布态校验与快照展示
2026-04-07 14:41:52 +08:00
7e7c236c2a fix: 修复管理端前端 lint 与构建问题
- 收敛 easyflow-ui-admin 的 lint、格式和类型问题

- 修正 demo 页面与管理端前端构建失败点

- 验证 pnpm lint 与 pnpm build 均已通过
2026-04-05 21:39:13 +08:00
bb72e19c84 fix: 修复管理端前端类型校验问题
- 修正知识库与 Bot 设置页相关组件的类型定义和空值处理

- 补齐工作流与公开聊天页的前端类型约束和动态导入类型

- 收敛本次改动文件的局部格式与样式规范,确保 pnpm check:type 通过
2026-04-05 20:36:25 +08:00
b5dd427920 feat: 收敛知识库检索调度与评分语义
- 固定 rag.engine 与 Milvus 配置,补齐启动期检索基础设施校验

- 支持调用方配置 retrievalMode,并统一知识库检索入口与结果来源展示

- 修正关键词检索 knowledgeId 过滤、混合检索评分归一化与本地 ES 默认配置
2026-04-05 20:23:05 +08:00
2592a1f09d fix: 修复工作流节点输入框光标错位
- 同步参数编辑器高亮层与真实输入层的内边距

- 调整多行换行规则,避免文本与光标在行尾错位
2026-04-05 19:31:15 +08:00
a402d920be chore: 清理旧版 Flyway 迁移目录
- 移除 starter 资源目录下已废弃的顶层 migration 脚本

- 保留按 mysql 和 analyticaldb 分目录后的迁移结构
2026-04-05 12:05:08 +08:00
58d6fb4e63 fix: 补充聊天历史图标选择项
- 在管理端图标选择器中加入 chat-history 图标
2026-04-05 12:04:39 +08:00
029f290c1b fix: 修正硅基流动供应商标识
- 将错误的 siliconlow 标识统一更正为 siliconflow

- 对齐后端网关、品牌配置和管理端模型列表
2026-04-05 12:04:20 +08:00
acbf087bf3 docs: 统一产品设计规范文档命名
- 将总规范文档重命名为 easyflow-product-design.md

- 修正 docs/design README 中的引用链接
2026-04-05 12:04:00 +08:00
a4f75a5e4c feat: 接入聊天历史界面与外链会话恢复
- 新增管理端与用户端聊天历史接口和页面

- 外链聊天支持访问令牌登录、身份保活与当前会话恢复

- 聊天执行链路切到统一 runtime 与 chatlog 查询接口
2026-04-05 11:37:25 +08:00
25e80433a5 feat: 落地聊天记录异步持久化基础设施
- 新增 chatlog 模块、AnalyticalDB 公共层与 common-mq Redis Streams 实现

- 建立 Redis 热态、MySQL 热数据、AnalyticalDB 历史查询与同步链路

- 收紧聊天记录幂等、摘要时序与持久化失败语义
2026-04-05 11:35:05 +08:00
1ecc28e498 feat: 工作流适配数据中枢查询节点
- 新增查询数据与写入数据节点并移除旧数据中心节点入口

- 将查询数据节点切换为连接服务加 SQL 的执行模型

- 同步更新工作流校验、提示词上下文与设计器交互
2026-04-02 18:56:34 +08:00
798effbd5b feat: 重构数据中枢工作台与接入管理
- 新增统一的数据源、目录、纳管表与 Excel 处理后端能力

- 重建管理端数据中枢工作台并替换旧表管理页面

- 补充数据中枢迁移脚本、连接器底座与说明字段支持
2026-04-02 18:55:31 +08:00
2611 changed files with 396519 additions and 18997 deletions

5
.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
@@ -42,6 +44,7 @@ build/
.jlsp/ .jlsp/
.arts/ .arts/
luceneKnowledge luceneKnowledge
**/*.lic
# v1 # v1
/easyflow-ui-react /easyflow-ui-react

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:
@@ -118,7 +120,32 @@ services:
minio-init: minio-init:
condition: service_completed_successfully condition: service_completed_successfully
ports: ports:
- "19530:19530" - "39530:19530"
- "9091:9091" - "9091:9091"
volumes: volumes:
- ./data/milvus:/var/lib/milvus - ./data/milvus:/var/lib/milvus
elasticsearch:
image: docker.elastic.co/elasticsearch/elasticsearch:8.15.5
container_name: easyflow-elasticsearch
restart: unless-stopped
environment:
TZ: Asia/Shanghai
discovery.type: single-node
ELASTIC_PASSWORD: elastic
xpack.security.enabled: "true"
ES_JAVA_OPTS: -Xms1g -Xmx1g
ports:
- "39200:9200"
volumes:
- ./data/elasticsearch:/usr/share/elasticsearch/data
healthcheck:
test:
[
"CMD-SHELL",
"curl -fsS -u elastic:elastic http://127.0.0.1:9200 >/dev/null || exit 1",
]
interval: 15s
timeout: 10s
retries: 20
start_period: 60s

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

@@ -12,7 +12,7 @@
### 1. 产品主题与总规范 ### 1. 产品主题与总规范
- [easyflow-product-design-spec.md](./easyflow-product-design-spec.md) - [easyflow-product-design.md](easyflow-product-design.md)
适用场景: 适用场景:

View File

@@ -12,10 +12,26 @@
<artifactId>easyflow-api-admin</artifactId> <artifactId>easyflow-api-admin</artifactId>
<dependencies> <dependencies>
<dependency>
<groupId>tech.easyflow</groupId>
<artifactId>easyflow-module-approval</artifactId>
</dependency>
<dependency> <dependency>
<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>
<groupId>tech.easyflow</groupId>
<artifactId>easyflow-module-chatlog</artifactId>
</dependency>
<dependency> <dependency>
<groupId>tech.easyflow</groupId> <groupId>tech.easyflow</groupId>
<artifactId>easyflow-module-auth</artifactId> <artifactId>easyflow-module-auth</artifactId>
@@ -24,9 +40,25 @@
<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>
</dependency> </dependency>
<dependency>
<groupId>junit</groupId>
<artifactId>junit</artifactId>
<version>${junit.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-core</artifactId>
<version>5.12.0</version>
<scope>test</scope>
</dependency>
</dependencies> </dependencies>
</project> </project>

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

@@ -13,20 +13,34 @@ import com.mybatisflex.core.query.QueryWrapper;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.util.StringUtils; import org.springframework.util.StringUtils;
import org.springframework.web.context.request.RequestContextHolder;
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.BotResourceCreatorNameSupport;
import tech.easyflow.admin.service.ai.ChatWorkspaceService;
import tech.easyflow.ai.chattime.availability.ChatTimeToolAvailabilityContext;
import tech.easyflow.ai.easyagents.listener.PromptChoreChatStreamListener; import tech.easyflow.ai.easyagents.listener.PromptChoreChatStreamListener;
import tech.easyflow.ai.entity.*; import tech.easyflow.ai.entity.*;
import tech.easyflow.ai.enums.PublishStatus;
import tech.easyflow.ai.publish.BotPublishAppService;
import tech.easyflow.approval.entity.vo.ApprovalActionResult;
import tech.easyflow.ai.service.*; import tech.easyflow.ai.service.*;
import tech.easyflow.ai.service.impl.BotServiceImpl; import tech.easyflow.ai.service.impl.BotServiceImpl;
import tech.easyflow.common.audio.core.AudioServiceManager; import tech.easyflow.common.audio.core.AudioServiceManager;
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 tech.easyflow.chatlog.service.ChatRoundOperateService;
import tech.easyflow.core.chat.protocol.sse.ChatSseEmitter; import tech.easyflow.core.chat.protocol.sse.ChatSseEmitter;
import tech.easyflow.core.chat.protocol.sse.ChatSseUtil; import tech.easyflow.core.chat.protocol.sse.ChatSseUtil;
import tech.easyflow.core.runtime.ChatChannel;
import tech.easyflow.core.runtime.ChatRuntimeExtKeys;
import tech.easyflow.core.runtime.ChatRuntimeContext;
import tech.easyflow.system.entity.vo.RoleCategoryAccessSnapshot; import tech.easyflow.system.entity.vo.RoleCategoryAccessSnapshot;
import tech.easyflow.system.service.CategoryPermissionService; import tech.easyflow.system.service.CategoryPermissionService;
@@ -63,6 +77,16 @@ public class BotController extends BaseCurdController<BotService, Bot> {
private AudioServiceManager audioServiceManager; private AudioServiceManager audioServiceManager;
@Resource @Resource
private CategoryPermissionService categoryPermissionService; private CategoryPermissionService categoryPermissionService;
@Resource
private BotPublishAppService botPublishAppService;
@Resource
private ChatRoundOperateService chatRoundOperateService;
@Resource
private BotApprovalStateService botApprovalStateService;
@Resource
private BotResourceCreatorNameSupport botResourceCreatorNameSupport;
@Resource
private ChatWorkspaceService chatWorkspaceService;
public BotController(BotService service, ModelService modelService, BotWorkflowService botWorkflowService, public BotController(BotService service, ModelService modelService, BotWorkflowService botWorkflowService,
BotDocumentCollectionService botDocumentCollectionService, BotMessageService botMessageService) { BotDocumentCollectionService botDocumentCollectionService, BotMessageService botMessageService) {
@@ -73,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;
@@ -148,17 +182,42 @@ public class BotController extends BaseCurdController<BotService, Bot> {
@JsonBody(value = "botId", required = true) BigInteger botId, @JsonBody(value = "botId", required = true) BigInteger botId,
@JsonBody(value = "conversationId", required = true) BigInteger conversationId, @JsonBody(value = "conversationId", required = true) BigInteger conversationId,
@JsonBody(value = "messages") List<Map<String, String>> messages, @JsonBody(value = "messages") List<Map<String, String>> messages,
@JsonBody(value = "attachments") List<String> attachments @JsonBody(value = "attachments") List<String> attachments,
@JsonBody(value = "publishedOnly") Boolean publishedOnly,
@JsonBody(value = "extraKnowledgeIds") List<BigInteger> extraKnowledgeIds,
@JsonBody(value = "regenerateRoundId") BigInteger regenerateRoundId
) { ) {
boolean usePublishedOnly = Boolean.TRUE.equals(publishedOnly);
BotServiceImpl.ChatCheckResult chatCheckResult = new BotServiceImpl.ChatCheckResult(); BotServiceImpl.ChatCheckResult chatCheckResult = new BotServiceImpl.ChatCheckResult();
if (usePublishedOnly) {
chatWorkspaceService.assertSessionContinuable(requireCurrentLoginAccount(), conversationId, botId);
}
if (regenerateRoundId != null) {
chatRoundOperateService.requireRegeneratableRound(conversationId, regenerateRoundId);
}
// 前置校验失败则直接返回错误SseEmitter // 前置校验失败则直接返回错误SseEmitter
SseEmitter errorEmitter = botService.checkChatBeforeStart(botId, prompt, conversationId.toString(), chatCheckResult); SseEmitter errorEmitter = botService.checkChatBeforeStart(
botId,
prompt,
conversationId.toString(),
chatCheckResult,
usePublishedOnly,
regenerateRoundId
);
if (errorEmitter != null) { if (errorEmitter != null) {
return errorEmitter; return errorEmitter;
} }
return botService.startChat(botId, prompt, conversationId, messages, chatCheckResult, attachments); return botService.startChat(
botId,
prompt,
conversationId,
messages,
chatCheckResult,
attachments,
buildRuntimeContext(chatCheckResult.getAiBot(), conversationId, prompt, attachments, extraKnowledgeIds, regenerateRoundId)
);
} }
@PostMapping("updateLlmId") @PostMapping("updateLlmId")
@@ -172,9 +231,26 @@ public class BotController extends BaseCurdController<BotService, Bot> {
@GetMapping("getDetail") @GetMapping("getDetail")
@SaIgnore @SaIgnore
public Result<Bot> getDetail(String id) { public Result<Bot> getDetail(String id) {
Bot bot = botService.getDetail(id); boolean publishedOnly = isPublishedOnlyRequest();
if (bot != null && StpUtil.isLogin()) { Bot rawBot = StpUtil.isLogin() ? botService.getDetail(id) : botService.getPublishedDetail(id);
categoryPermissionService.assertCategoryResourceVisible("BOT", bot.getCreatedBy(), bot.getCategoryId(), "无权限访问聊天助手"); if (rawBot != null && StpUtil.isLogin()) {
categoryPermissionService.assertCategoryResourceVisible("BOT", rawBot.getCreatedBy(), rawBot.getCategoryId(), "无权限访问聊天助手");
}
if (rawBot == null) {
return Result.ok(null);
}
if (!StpUtil.isLogin() && !PublishStatus.from(rawBot.getPublishStatus()).isExternallyVisible()) {
throw new BusinessException("聊天助手尚未发布");
}
Bot bot = rawBot;
if (publishedOnly && StpUtil.isLogin()) {
if (PublishStatus.from(rawBot.getPublishStatus()) != PublishStatus.PUBLISHED) {
throw new BusinessException("聊天助手尚未发布");
}
bot = botService.toPublishedView(rawBot);
}
if (StpUtil.isLogin()) {
botApprovalStateService.fillApprovalState(bot);
} }
return Result.ok(bot); return Result.ok(bot);
} }
@@ -182,12 +258,24 @@ public class BotController extends BaseCurdController<BotService, Bot> {
@Override @Override
@SaIgnore @SaIgnore
public Result<Bot> detail(String id) { public Result<Bot> detail(String id) {
Bot data = botService.getDetail(id); boolean publishedOnly = isPublishedOnlyRequest();
if (data == null) { Bot rawData = StpUtil.isLogin() ? botService.getDetail(id) : botService.getPublishedDetail(id);
return Result.ok(data); if (rawData == null) {
return Result.ok(rawData);
} }
if (StpUtil.isLogin()) { if (StpUtil.isLogin()) {
categoryPermissionService.assertCategoryResourceVisible("BOT", data.getCreatedBy(), data.getCategoryId(), "无权限访问聊天助手"); categoryPermissionService.assertCategoryResourceVisible("BOT", rawData.getCreatedBy(), rawData.getCategoryId(), "无权限访问聊天助手");
}
if (!StpUtil.isLogin() && !PublishStatus.from(rawData.getPublishStatus()).isExternallyVisible()) {
throw new BusinessException("聊天助手尚未发布");
}
Bot data = rawData;
if (publishedOnly && StpUtil.isLogin()) {
if (PublishStatus.from(rawData.getPublishStatus()) != PublishStatus.PUBLISHED) {
throw new BusinessException("聊天助手尚未发布");
}
data = botService.toPublishedView(rawData);
} }
Map<String, Object> llmOptions = data.getModelOptions(); Map<String, Object> llmOptions = data.getModelOptions();
@@ -196,6 +284,9 @@ public class BotController extends BaseCurdController<BotService, Bot> {
} }
if (data.getModelId() == null) { if (data.getModelId() == null) {
if (StpUtil.isLogin()) {
botApprovalStateService.fillApprovalState(data);
}
return Result.ok(data); return Result.ok(data);
} }
@@ -204,6 +295,9 @@ public class BotController extends BaseCurdController<BotService, Bot> {
if (llm == null) { if (llm == null) {
data.setModelId(null); data.setModelId(null);
if (StpUtil.isLogin()) {
botApprovalStateService.fillApprovalState(data);
}
return Result.ok(data); return Result.ok(data);
} }
@@ -217,21 +311,98 @@ public class BotController extends BaseCurdController<BotService, Bot> {
} }
if (StpUtil.isLogin()) {
botApprovalStateService.fillApprovalState(data);
}
return Result.ok(data); return Result.ok(data);
} }
/**
* 提交聊天助手发布审批。
*
* @param id 助手 ID
* @param applicationReason 审批说明
* @return 审批实例 ID
*/
@PostMapping("/submitPublishApproval")
@SaCheckPermission("/api/v1/bot/save")
public Result<BigInteger> submitPublishApproval(
@JsonBody("id") BigInteger id,
@JsonBody("applicationReason") String applicationReason
) {
return buildApprovalActionResult(
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")
@SaCheckPermission("/api/v1/bot/save")
public Result<BigInteger> submitOfflineApproval(@JsonBody("id") BigInteger id) {
return buildApprovalActionResult(
botPublishAppService.submitOfflineApproval(id),
"已提交下线审批",
"已直接下线"
);
}
@PostMapping("/submitDeleteApproval")
@SaCheckPermission("/api/v1/bot/remove")
public Result<BigInteger> submitDeleteApproval(@JsonBody("id") BigInteger id) {
return buildApprovalActionResult(
botPublishAppService.submitDeleteApproval(id),
"已提交删除审批",
"已直接删除"
);
}
@Override @Override
public Result<List<Bot>> list(Bot entity, Boolean asTree, String sortKey, String sortType) { public Result<List<Bot>> list(Bot entity, Boolean asTree, String sortKey, String sortType) {
QueryWrapper queryWrapper = QueryWrapper.create(entity, buildOperators(entity)); QueryWrapper queryWrapper = QueryWrapper.create(entity, buildOperators(entity));
applyCategoryPermission(queryWrapper); applyCategoryPermission(queryWrapper);
applyPublishedOnlyFilter(queryWrapper);
queryWrapper.orderBy(buildOrderBy(sortKey, sortType, getDefaultOrderBy())); queryWrapper.orderBy(buildOrderBy(sortKey, sortType, getDefaultOrderBy()));
return Result.ok(service.list(queryWrapper)); List<Bot> bots = service.list(queryWrapper);
if (isPublishedOnlyRequest()) {
bots = bots.stream().map(botService::toPublishedView).toList();
}
botApprovalStateService.fillApprovalState(bots);
return Result.ok(bots);
} }
@Override @Override
protected Page<Bot> queryPage(Page<Bot> page, QueryWrapper queryWrapper) { protected Page<Bot> queryPage(Page<Bot> page, QueryWrapper queryWrapper) {
applyCategoryPermission(queryWrapper); applyCategoryPermission(queryWrapper);
return super.queryPage(page, queryWrapper); applyPublishedOnlyFilter(queryWrapper);
Page<Bot> result = super.queryPage(page, queryWrapper);
if (isPublishedOnlyRequest()) {
result.setRecords(result.getRecords().stream().map(botService::toPublishedView).toList());
}
botApprovalStateService.fillApprovalState(result.getRecords());
botResourceCreatorNameSupport.fillCreatorNames(result.getRecords());
return result;
}
private Result<BigInteger> buildApprovalActionResult(ApprovalActionResult actionResult,
String approvalMessage,
String directMessage) {
return Result.ok(
actionResult.isApprovalRequired() ? approvalMessage : directMessage,
actionResult.getInstanceId()
);
} }
private void applyCategoryPermission(QueryWrapper queryWrapper) { private void applyCategoryPermission(QueryWrapper queryWrapper) {
@@ -319,6 +490,55 @@ public class BotController extends BaseCurdController<BotService, Bot> {
return result; return result;
} }
private ChatRuntimeContext buildRuntimeContext(Bot bot, BigInteger conversationId, String prompt, List<String> attachments,
List<BigInteger> extraKnowledgeIds,
BigInteger regenerateRoundId) {
LoginAccount account = requireCurrentLoginAccount();
ChatRuntimeContext context = new ChatRuntimeContext();
context.setChannel(ChatChannel.ADMIN);
context.setSessionId(conversationId);
context.setTenantId(account.getTenantId());
context.setDeptId(account.getDeptId());
context.setUserId(account.getId());
context.setUserAccount(account.getLoginName());
context.setUserName(StringUtils.hasText(account.getNickname()) ? account.getNickname() : account.getLoginName());
context.setAssistantId(bot == null ? BigInteger.ZERO : bot.getId());
context.setAssistantCode(bot == null ? null : bot.getAlias());
context.setAssistantName(bot == null ? null : bot.getTitle());
context.setSessionTitle(prompt.length() > 200 ? prompt.substring(0, 200) : prompt);
context.setAttachments(attachments);
if (extraKnowledgeIds != null) {
context.getExt().put(ChatRuntimeExtKeys.EXTRA_KNOWLEDGE_IDS, extraKnowledgeIds);
}
if (regenerateRoundId != null) {
context.getExt().put(ChatRuntimeExtKeys.REGENERATE_ROUND_ID, regenerateRoundId);
}
ChatTimeToolAvailabilityContext.bindLoggedInSnapshot(context, account, bot);
return context;
}
private void applyPublishedOnlyFilter(QueryWrapper queryWrapper) {
if (isPublishedOnlyRequest()) {
queryWrapper.eq("publish_status", PublishStatus.PUBLISHED.getCode());
}
}
private boolean isPublishedOnlyRequest() {
ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
if (attributes == null) {
return false;
}
return "true".equalsIgnoreCase(attributes.getRequest().getParameter("publishedOnly"));
}
private LoginAccount requireCurrentLoginAccount() {
try {
return SaTokenUtil.getLoginAccount();
} catch (Exception e) {
throw new BusinessException("当前登录状态失效,请重新登录后再试");
}
}
@Override @Override
protected Result<?> onRemoveBefore(Collection<Serializable> ids) { protected Result<?> onRemoveBefore(Collection<Serializable> ids) {
QueryWrapper queryWrapperKnowledge = QueryWrapper.create().in(BotDocumentCollection::getBotId, ids); QueryWrapper queryWrapperKnowledge = QueryWrapper.create().in(BotDocumentCollection::getBotId, ids);
@@ -330,6 +550,12 @@ public class BotController extends BaseCurdController<BotService, Bot> {
return super.onRemoveBefore(ids); return super.onRemoveBefore(ids);
} }
@Override
@PostMapping("remove")
public Result<?> remove(@JsonBody(value = "id", required = true) Serializable id) {
return Result.fail(1, "请使用发布状态操作删除聊天助手");
}
/** /**
* 系统提示词优化 * 系统提示词优化
* *

View File

@@ -1,8 +1,10 @@
package tech.easyflow.admin.controller.ai; package tech.easyflow.admin.controller.ai;
import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PostMapping;
import tech.easyflow.ai.dto.BotKnowledgeBindingRequest;
import tech.easyflow.ai.entity.BotDocumentCollection; import tech.easyflow.ai.entity.BotDocumentCollection;
import tech.easyflow.ai.entity.DocumentCollection; import tech.easyflow.ai.entity.DocumentCollection;
import tech.easyflow.ai.enums.PublishStatus;
import tech.easyflow.ai.permission.KnowledgeReadAccessSnapshot; import tech.easyflow.ai.permission.KnowledgeReadAccessSnapshot;
import tech.easyflow.ai.permission.KnowledgeVisibilityQueryHelper; import tech.easyflow.ai.permission.KnowledgeVisibilityQueryHelper;
import tech.easyflow.ai.service.BotDocumentCollectionService; import tech.easyflow.ai.service.BotDocumentCollectionService;
@@ -63,20 +65,24 @@ public class BotDocumentCollectionController extends BaseCurdController<BotDocum
} }
@PostMapping("updateBotKnowledgeIds") @PostMapping("updateBotKnowledgeIds")
public Result<?> save(@JsonBody("botId") BigInteger botId, @JsonBody("knowledgeIds") BigInteger [] knowledgeIds) { public Result<?> save(@JsonBody("botId") BigInteger botId,
if (knowledgeIds != null) { @JsonBody("knowledgeBindings") List<BotKnowledgeBindingRequest> knowledgeBindings) {
for (BigInteger knowledgeId : knowledgeIds) { if (knowledgeBindings != null) {
if (knowledgeId == null) { for (BotKnowledgeBindingRequest binding : knowledgeBindings) {
if (binding == null || binding.getKnowledgeId() == null) {
continue; continue;
} }
DocumentCollection collection = documentCollectionService.getById(knowledgeId); DocumentCollection collection = documentCollectionService.getById(binding.getKnowledgeId());
if (collection == null) { if (collection == null) {
continue; continue;
} }
resourceAccessService.assertAccess(CategoryResourceType.KNOWLEDGE, collection, ResourceAction.READ, "无权限绑定知识库"); resourceAccessService.assertAccess(CategoryResourceType.KNOWLEDGE, collection, ResourceAction.READ, "无权限绑定知识库");
if (PublishStatus.from(collection.getPublishStatus()) != PublishStatus.PUBLISHED) {
throw new tech.easyflow.common.web.exceptions.BusinessException("仅已发布知识库可被聊天助手引用");
}
} }
} }
service.saveBotAndKnowledge(botId, knowledgeIds); service.saveBotAndKnowledge(botId, knowledgeBindings);
return Result.ok(); return Result.ok();
} }
} }

View File

@@ -4,6 +4,7 @@ import org.springframework.web.bind.annotation.PostMapping;
import tech.easyflow.ai.entity.Plugin; import tech.easyflow.ai.entity.Plugin;
import tech.easyflow.ai.entity.BotPlugin; import tech.easyflow.ai.entity.BotPlugin;
import tech.easyflow.ai.entity.PluginItem; import tech.easyflow.ai.entity.PluginItem;
import tech.easyflow.ai.enums.PluginType;
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.tree.Tree; import tech.easyflow.common.tree.Tree;
@@ -58,7 +59,14 @@ public class BotPluginController extends BaseCurdController<BotPluginService, Bo
List<BotPlugin> visibleList = new ArrayList<>(); List<BotPlugin> visibleList = new ArrayList<>();
for (BotPlugin relation : botPlugins) { for (BotPlugin relation : botPlugins) {
Plugin plugin = relation.getAiPlugin(); Plugin plugin = relation.getAiPlugin();
if (plugin == null || pluginVisibilityService.canAccessPlugin(plugin.getCreatedBy(), plugin.getId())) { if (plugin == null) {
visibleList.add(relation);
continue;
}
if (PluginType.isWorkflow(plugin.getType())) {
continue;
}
if (pluginVisibilityService.canAccessPlugin(plugin.getCreatedBy(), plugin.getId())) {
visibleList.add(relation); visibleList.add(relation);
} }
} }
@@ -73,7 +81,13 @@ public class BotPluginController extends BaseCurdController<BotPluginService, Bo
List<Plugin> plugins = botPluginService.getList(botId); List<Plugin> plugins = botPluginService.getList(botId);
List<Plugin> visibleList = new ArrayList<>(); List<Plugin> visibleList = new ArrayList<>();
for (Plugin plugin : plugins) { for (Plugin plugin : plugins) {
if (plugin == null || pluginVisibilityService.canAccessPlugin(plugin.getCreatedBy(), plugin.getId())) { if (plugin == null) {
continue;
}
if (PluginType.isWorkflow(plugin.getType())) {
continue;
}
if (pluginVisibilityService.canAccessPlugin(plugin.getCreatedBy(), plugin.getId())) {
visibleList.add(plugin); visibleList.add(plugin);
} }
} }
@@ -105,6 +119,9 @@ public class BotPluginController extends BaseCurdController<BotPluginService, Bo
if (pluginItem.getPluginId() != null) { if (pluginItem.getPluginId() != null) {
Plugin plugin = pluginService.getById(pluginItem.getPluginId()); Plugin plugin = pluginService.getById(pluginItem.getPluginId());
if (plugin != null) { if (plugin != null) {
if (PluginType.isWorkflow(plugin.getType())) {
throw new tech.easyflow.common.web.exceptions.BusinessException("当前版本暂不支持聊天助手绑定工作流插件");
}
pluginVisibilityService.assertPluginVisible(plugin.getCreatedBy(), plugin.getId(), "无权限绑定插件"); pluginVisibilityService.assertPluginVisible(plugin.getCreatedBy(), plugin.getId(), "无权限绑定插件");
} }
} }

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

@@ -3,6 +3,7 @@ package tech.easyflow.admin.controller.ai;
import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PostMapping;
import tech.easyflow.ai.entity.BotWorkflow; import tech.easyflow.ai.entity.BotWorkflow;
import tech.easyflow.ai.entity.Workflow; import tech.easyflow.ai.entity.Workflow;
import tech.easyflow.ai.enums.PublishStatus;
import tech.easyflow.ai.permission.WorkflowReadAccessSnapshot; import tech.easyflow.ai.permission.WorkflowReadAccessSnapshot;
import tech.easyflow.ai.permission.WorkflowVisibilityQueryHelper; import tech.easyflow.ai.permission.WorkflowVisibilityQueryHelper;
import tech.easyflow.ai.service.BotWorkflowService; import tech.easyflow.ai.service.BotWorkflowService;
@@ -77,6 +78,9 @@ public class BotWorkflowController extends BaseCurdController<BotWorkflowService
continue; continue;
} }
resourceAccessService.assertAccess(CategoryResourceType.WORKFLOW, workflow, ResourceAction.READ, "无权限绑定工作流"); resourceAccessService.assertAccess(CategoryResourceType.WORKFLOW, workflow, ResourceAction.READ, "无权限绑定工作流");
if (PublishStatus.from(workflow.getPublishStatus()) != PublishStatus.PUBLISHED) {
throw new tech.easyflow.common.web.exceptions.BusinessException("仅已发布工作流可被聊天助手引用");
}
} }
} }
service.saveBotAndWorkflowTool(botId, workflowIds); service.saveBotAndWorkflowTool(botId, workflowIds);

View File

@@ -0,0 +1,151 @@
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.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
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.ChatMessageRecord;
import tech.easyflow.chatlog.domain.dto.ChatSessionPage;
import tech.easyflow.chatlog.domain.dto.ChatSessionSummary;
import tech.easyflow.chatlog.domain.query.ChatPageQuery;
import tech.easyflow.chatlog.domain.query.ChatSessionFilterQuery;
import tech.easyflow.chatlog.service.ChatHistoryManageService;
import tech.easyflow.common.domain.Result;
import tech.easyflow.common.entity.LoginAccount;
import tech.easyflow.common.satoken.util.SaTokenUtil;
import tech.easyflow.common.web.jsonbody.JsonBody;
import tech.easyflow.system.service.CategoryPermissionService;
import java.math.BigInteger;
import java.util.List;
@RestController
@RequestMapping("/api/v1/chatHistory")
@SaCheckPermission("/api/v1/chatHistory/query")
public class ChatHistoryController {
private final ChatHistoryManageService chatHistoryManageService;
private final CategoryPermissionService categoryPermissionService;
private final AgentOptionQueryService agentOptionQueryService;
/**
* 创建聊天历史控制器。
*
* @param chatHistoryManageService 聊天历史管理服务
* @param categoryPermissionService 账号权限服务
* @param agentOptionQueryService Agent 安全选项服务
*/
public ChatHistoryController(ChatHistoryManageService chatHistoryManageService,
CategoryPermissionService categoryPermissionService,
AgentOptionQueryService agentOptionQueryService) {
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")
public Result<ChatSessionPage> listSessions(ChatSessionFilterQuery query) {
LoginAccount account = SaTokenUtil.getLoginAccount();
return Result.ok(chatHistoryManageService.queryAdminSessions(
account.getId(),
categoryPermissionService.isSuperAdmin(account),
query
));
}
/**
* 获取当前账号可见的 Agent 会话详情。
*
* @param sessionId 会话 ID
* @return 会话详情
*/
@GetMapping("/sessions/{sessionId}")
public Result<ChatSessionSummary> getSession(@PathVariable BigInteger 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")
public Result<ChatHistoryPage> queryMessages(@PathVariable BigInteger sessionId, ChatPageQuery 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")
public Result<List<ChatMessageRecord>> listRoundVariants(@PathVariable BigInteger sessionId,
@PathVariable BigInteger 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")
public Result<ChatMessageRecord> selectRoundVariant(@PathVariable BigInteger sessionId,
@PathVariable BigInteger roundId,
@JsonBody(value = "variantIndex", required = true) Integer variantIndex) {
LoginAccount account = SaTokenUtil.getLoginAccount();
return Result.ok(chatHistoryManageService.selectAdminRoundVariant(
account.getId(),
categoryPermissionService.isSuperAdmin(account),
sessionId,
roundId,
variantIndex
));
}
}

View File

@@ -0,0 +1,86 @@
package tech.easyflow.admin.controller.ai;
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.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
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.ai.ChatWorkspaceService;
import tech.easyflow.chatlog.domain.dto.ChatHistoryPage;
import tech.easyflow.chatlog.domain.dto.ChatMessageRecord;
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.jsonbody.JsonBody;
import java.math.BigInteger;
import java.util.List;
import java.util.Map;
/**
* 管理端聊天工作台控制器。
*/
@RestController
@RequestMapping("/api/v1/chatWorkspace")
public class ChatWorkspaceController {
private final ChatWorkspaceService chatWorkspaceService;
public ChatWorkspaceController(ChatWorkspaceService chatWorkspaceService) {
this.chatWorkspaceService = chatWorkspaceService;
}
@GetMapping("/sessions")
public Result<ChatWorkspaceSessionPage> listSessions(BigInteger assistantId, ChatPageQuery query) {
return Result.ok(chatWorkspaceService.queryCurrentUserSessions(currentAccount(), assistantId, query));
}
@GetMapping("/sessions/{sessionId}")
public Result<ChatWorkspaceSessionDetailView> getSession(@PathVariable BigInteger sessionId) {
return Result.ok(chatWorkspaceService.getCurrentUserSession(currentAccount(), sessionId));
}
@GetMapping("/sessions/{sessionId}/messages")
public Result<ChatHistoryPage> queryMessages(@PathVariable BigInteger sessionId, ChatPageQuery query) {
return Result.ok(chatWorkspaceService.queryCurrentUserMessages(currentAccount(), sessionId, query));
}
@GetMapping("/sessions/{sessionId}/conversation")
public Result<ChatWorkspaceConversationView> getConversation(@PathVariable BigInteger sessionId) {
return Result.ok(chatWorkspaceService.getCurrentUserConversation(currentAccount(), sessionId));
}
@PostMapping("/sessions/{sessionId}/rename")
public Result<Void> renameSession(@PathVariable BigInteger sessionId,
@JsonBody(value = "title", required = true) String title) {
chatWorkspaceService.renameCurrentUserSession(currentAccount(), sessionId, title);
return Result.ok();
}
@PostMapping("/sessions/{sessionId}/delete")
public Result<Void> deleteSession(@PathVariable BigInteger sessionId) {
chatWorkspaceService.deleteCurrentUserSession(currentAccount(), sessionId);
return Result.ok();
}
@GetMapping("/sessions/{sessionId}/rounds/{roundId}/variants")
public Result<List<ChatMessageRecord>> listRoundVariants(@PathVariable BigInteger sessionId,
@PathVariable BigInteger roundId) {
return Result.ok(chatWorkspaceService.listCurrentUserRoundVariants(currentAccount(), sessionId, roundId));
}
@PostMapping("/sessions/{sessionId}/rounds/{roundId}/selectVariant")
public Result<ChatMessageRecord> selectRoundVariant(@PathVariable BigInteger sessionId,
@PathVariable BigInteger roundId,
@JsonBody(value = "variantIndex", required = true) Integer variantIndex) {
return Result.ok(chatWorkspaceService.selectCurrentUserRoundVariant(currentAccount(), sessionId, roundId, variantIndex));
}
private LoginAccount currentAccount() {
return SaTokenUtil.getLoginAccount();
}
}

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();
if (documentStore == null) {
return Result.fail(2, "知识库没有配置向量库");
}
// 设置向量模型
Model model = modelService.getModelInstance(knowledge.getVectorEmbedModelId());
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); DocumentChunk updated = documentChunkService.updateContent(
current.getDocumentCollectionId(),
current.getId(),
request.getContent()
);
return Result.ok(updated);
} }
@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

@@ -2,20 +2,32 @@ package tech.easyflow.admin.controller.ai;
import cn.dev33.satoken.annotation.SaCheckPermission; import cn.dev33.satoken.annotation.SaCheckPermission;
import com.easyagents.core.document.Document; import com.easyagents.core.document.Document;
import com.easyagents.rag.retrieval.RagRetrievalMetadataKeys;
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 org.springframework.util.StringUtils; import org.springframework.util.StringUtils;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
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;
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.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.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.entity.BotDocumentCollection; import tech.easyflow.ai.dto.KnowledgeSearchResultItem;
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.service.BotDocumentCollectionService; import tech.easyflow.ai.enums.PublishStatus;
import tech.easyflow.ai.publish.KnowledgePublishAppService;
import tech.easyflow.ai.service.AiResourceApprovalStateService;
import tech.easyflow.ai.vo.OfflineImpactCheckVo;
import tech.easyflow.approval.entity.vo.ApprovalActionResult;
import tech.easyflow.ai.rag.KnowledgeRetrievalRequest;
import tech.easyflow.ai.rag.KnowledgeRetrievalModes;
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;
@@ -32,11 +44,15 @@ import tech.easyflow.system.service.ResourceAccessService;
import javax.annotation.Resource; import javax.annotation.Resource;
import java.io.Serializable; import java.io.Serializable;
import java.math.BigDecimal;
import java.math.BigInteger; import java.math.BigInteger;
import java.math.RoundingMode;
import java.util.ArrayList;
import java.util.Collection; import java.util.Collection;
import java.util.HashMap; import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.UUID;
/** /**
* 控制层。 * 控制层。
@@ -52,11 +68,17 @@ 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
private KnowledgeVisibilityQueryHelper knowledgeVisibilityQueryHelper; private KnowledgeVisibilityQueryHelper knowledgeVisibilityQueryHelper;
@Resource
private KnowledgePublishAppService knowledgePublishAppService;
@Resource
private AiResourceApprovalStateService aiResourceApprovalStateService;
@Resource
private AiResourceCreatorNameSupport aiResourceCreatorNameSupport;
public DocumentCollectionController(DocumentCollectionService service, DocumentChunkService chunkService, ModelService llmService) { public DocumentCollectionController(DocumentCollectionService service, DocumentChunkService chunkService, ModelService llmService) {
super(service); super(service);
@@ -64,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);
@@ -105,13 +137,11 @@ public class DocumentCollectionController extends BaseCurdController<DocumentCol
Map<String, Object> options = entity.getOptions() == null Map<String, Object> options = entity.getOptions() == null
? new HashMap<>() ? new HashMap<>()
: new HashMap<>(entity.getOptions()); : new HashMap<>(entity.getOptions());
if (entity.getSearchEngineEnable() == null){
entity.setSearchEngineEnable(false);
}
options.putIfAbsent(DocumentCollection.KEY_CAN_UPDATE_EMBEDDING_MODEL, true); options.putIfAbsent(DocumentCollection.KEY_CAN_UPDATE_EMBEDDING_MODEL, true);
options.putIfAbsent(DocumentCollection.KEY_RERANK_ENABLE, entity.getRerankModelId() != null); options.putIfAbsent(DocumentCollection.KEY_RERANK_ENABLE, entity.getRerankModelId() != null);
entity.setOptions(options); entity.setOptions(options);
} }
normalizeInfrastructureFields(entity, isSave);
return super.onSaveOrUpdateBefore(entity, isSave); return super.onSaveOrUpdateBefore(entity, isSave);
} }
@@ -124,8 +154,20 @@ public class DocumentCollectionController extends BaseCurdController<DocumentCol
idExpr = "#knowledgeId", idExpr = "#knowledgeId",
denyMessage = "无权限访问知识库" denyMessage = "无权限访问知识库"
) )
public Result<List<Document>> search(@RequestParam BigInteger knowledgeId, @RequestParam String keyword) { public Result<List<KnowledgeSearchResultItem>> search(@RequestParam BigInteger knowledgeId,
return Result.ok(service.search(knowledgeId, keyword)); @RequestParam String keyword,
@RequestParam(required = false) String retrievalMode,
@RequestParam(required = false) Integer docRecallMaxNum,
@RequestParam(required = false) Double simThreshold) {
KnowledgeRetrievalRequest request = new KnowledgeRetrievalRequest();
request.setKnowledgeId(knowledgeId);
request.setQuery(keyword);
request.setLimit(docRecallMaxNum);
request.setMinSimilarity(simThreshold);
request.setRetrievalMode(KnowledgeRetrievalModes.parse(retrievalMode));
request.setCallerType("API");
request.setCallerId(String.valueOf(knowledgeId));
return Result.ok(toKnowledgeSearchResult(service.search(request)));
} }
@@ -137,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;
@@ -157,6 +199,7 @@ public class DocumentCollectionController extends BaseCurdController<DocumentCol
) )
public Result<DocumentCollection> detail(String id) { public Result<DocumentCollection> detail(String id) {
DocumentCollection detail = service.getDetail(id); DocumentCollection detail = service.getDetail(id);
aiResourceApprovalStateService.fillKnowledgeApprovalState(detail);
return Result.ok(detail); return Result.ok(detail);
} }
@@ -166,6 +209,89 @@ public class DocumentCollectionController extends BaseCurdController<DocumentCol
return Result.ok(llmService.listSelectableModels(entity, asTree, sortKey, sortType)); return Result.ok(llmService.listSelectableModels(entity, asTree, sortKey, sortType));
} }
/**
* 提交发布审批。
*
* @param id 知识库 ID
* @param applicationReason 审批说明
* @return 审批实例 ID
*/
@PostMapping("/submitPublishApproval")
@SaCheckPermission("/api/v1/documentCollection/save")
public Result<BigInteger> submitPublishApproval(
@JsonBody("id") BigInteger id,
@JsonBody("applicationReason") String applicationReason
) {
return buildApprovalActionResult(
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));
}
/**
* 提交下线审批。
*
* @param id 知识库 ID
* @return 审批实例 ID
*/
@PostMapping("/submitOfflineApproval")
@SaCheckPermission("/api/v1/documentCollection/save")
public Result<BigInteger> submitOfflineApproval(@JsonBody("id") BigInteger id) {
return buildApprovalActionResult(
knowledgePublishAppService.submitOfflineApproval(id),
"已提交下线审批",
"已直接下线"
);
}
/**
* 检查知识库下线影响。
*
* @param id 知识库 ID
* @return 下线影响结果
*/
@GetMapping("/offlineImpactCheck")
@SaCheckPermission("/api/v1/documentCollection/save")
@RequireResourceAccess(
resource = CategoryResourceType.KNOWLEDGE,
action = ResourceAction.MANAGE,
lookup = ResourceLookup.KNOWLEDGE_ID,
idExpr = "#id",
denyMessage = "无权限管理知识库"
)
public Result<OfflineImpactCheckVo> offlineImpactCheck(@RequestParam BigInteger id) {
return Result.ok(knowledgePublishAppService.checkOfflineImpact(id));
}
/**
* 提交删除审批。
*
* @param id 知识库 ID
* @return 审批实例 ID
*/
@PostMapping("/submitDeleteApproval")
@SaCheckPermission("/api/v1/documentCollection/remove")
public Result<BigInteger> submitDeleteApproval(@JsonBody("id") BigInteger id) {
return buildApprovalActionResult(
knowledgePublishAppService.submitDeleteApproval(id),
"已提交删除审批",
"已直接删除"
);
}
@PostMapping("splitterProfile/save") @PostMapping("splitterProfile/save")
@SaCheckPermission("/api/v1/documentCollection/save") @SaCheckPermission("/api/v1/documentCollection/save")
@RequireResourceAccess( @RequireResourceAccess(
@@ -204,14 +330,36 @@ public class DocumentCollectionController extends BaseCurdController<DocumentCol
public Result<List<DocumentCollection>> list(DocumentCollection entity, Boolean asTree, String sortKey, String sortType) { public Result<List<DocumentCollection>> list(DocumentCollection entity, Boolean asTree, String sortKey, String sortType) {
QueryWrapper queryWrapper = QueryWrapper.create(entity, buildOperators(entity)); QueryWrapper queryWrapper = QueryWrapper.create(entity, buildOperators(entity));
knowledgeVisibilityQueryHelper.applyReadableAccess(queryWrapper); knowledgeVisibilityQueryHelper.applyReadableAccess(queryWrapper);
applyPublishedOnlyFilter(queryWrapper);
queryWrapper.orderBy(buildOrderBy(sortKey, sortType, getDefaultOrderBy())); queryWrapper.orderBy(buildOrderBy(sortKey, sortType, getDefaultOrderBy()));
return Result.ok(service.list(queryWrapper)); List<DocumentCollection> collections = service.list(queryWrapper);
aiResourceApprovalStateService.fillKnowledgeApprovalState(collections);
return Result.ok(collections);
} }
@Override @Override
protected Page<DocumentCollection> queryPage(Page<DocumentCollection> page, QueryWrapper queryWrapper) { protected Page<DocumentCollection> queryPage(Page<DocumentCollection> page, QueryWrapper queryWrapper) {
knowledgeVisibilityQueryHelper.applyReadableAccess(queryWrapper); knowledgeVisibilityQueryHelper.applyReadableAccess(queryWrapper);
return super.queryPage(page, queryWrapper); applyPublishedOnlyFilter(queryWrapper);
Page<DocumentCollection> result = super.queryPage(page, queryWrapper);
aiResourceApprovalStateService.fillKnowledgeApprovalState(result.getRecords());
aiResourceCreatorNameSupport.fillDocumentCollectionCreatorNames(result.getRecords());
return result;
}
@Override
@PostMapping("remove")
public Result<?> remove(@JsonBody(value = "id", required = true) Serializable id) {
return Result.fail(1, "请使用发布状态操作删除知识库");
}
private Result<BigInteger> buildApprovalActionResult(ApprovalActionResult actionResult,
String approvalMessage,
String directMessage) {
return Result.ok(
actionResult.isApprovalRequired() ? approvalMessage : directMessage,
actionResult.getInstanceId()
);
} }
private void normalizeVisibilityScope(DocumentCollection entity, boolean isSave) { private void normalizeVisibilityScope(DocumentCollection entity, boolean isSave) {
@@ -234,4 +382,98 @@ public class DocumentCollectionController extends BaseCurdController<DocumentCol
} }
return collection; return collection;
} }
private void normalizeInfrastructureFields(DocumentCollection entity, boolean isSave) {
if (entity == null) {
return;
}
if (isSave) {
entity.setVectorStoreEnable(true);
entity.setVectorStoreType("milvus");
entity.setSearchEngineEnable(true);
entity.setVectorStoreCollection(generateVectorCollectionName());
return;
}
if (entity.getVectorStoreEnable() != null) {
entity.setVectorStoreEnable(true);
}
if (entity.getVectorStoreType() != null) {
entity.setVectorStoreType("milvus");
}
if (entity.getSearchEngineEnable() != null) {
entity.setSearchEngineEnable(true);
}
}
private String generateVectorCollectionName() {
return "kb_" + UUID.randomUUID().toString().replace("-", "").substring(0, 28);
}
private void applyPublishedOnlyFilter(QueryWrapper queryWrapper) {
ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
if (attributes == null) {
return;
}
String publishedOnly = attributes.getRequest().getParameter("publishedOnly");
if ("true".equalsIgnoreCase(publishedOnly)) {
queryWrapper.eq("publish_status", PublishStatus.PUBLISHED.getCode());
}
}
private List<KnowledgeSearchResultItem> toKnowledgeSearchResult(List<Document> documents) {
List<KnowledgeSearchResultItem> results = new ArrayList<>();
if (documents == null) {
return results;
}
for (int index = 0; index < documents.size(); index++) {
Document document = documents.get(index);
if (document == null) {
continue;
}
KnowledgeSearchResultItem item = new KnowledgeSearchResultItem();
item.setSorting(index + 1);
item.setContent(document.getContent());
item.setRenderMarkdown(readMetadataAsString(document, "renderMarkdown"));
item.setSourceFileName(readMetadataAsString(document, "sourceFileName"));
item.setScore(roundScore(document.getScore()));
item.setHitSource(readMetadataAsString(document, RagRetrievalMetadataKeys.HIT_SOURCE));
item.setVectorScore(roundScore(readMetadataAsDouble(document, RagRetrievalMetadataKeys.VECTOR_SCORE)));
item.setKeywordScore(roundScore(readMetadataAsDouble(document, RagRetrievalMetadataKeys.KEYWORD_SCORE)));
results.add(item);
}
return results;
}
private String readMetadataAsString(Document document, String key) {
Object value = document == null ? null : document.getMetadata(key);
if (value == null) {
return null;
}
String text = String.valueOf(value);
return StringUtils.hasText(text) ? text : null;
}
private Double readMetadataAsDouble(Document document, String key) {
Object value = document == null ? null : document.getMetadata(key);
if (value instanceof Number) {
return ((Number) value).doubleValue();
}
if (value instanceof String && StringUtils.hasText((String) value)) {
try {
return Double.valueOf((String) value);
} catch (NumberFormatException ignore) {
return null;
}
}
return null;
}
private Double roundScore(Double value) {
if (value == null) {
return null;
}
return new BigDecimal(String.valueOf(value))
.setScale(4, RoundingMode.HALF_UP)
.doubleValue();
}
} }

View File

@@ -8,9 +8,15 @@ import jakarta.servlet.http.HttpServletResponse;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.io.ClassPathResource; import org.springframework.core.io.ClassPathResource;
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 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.entity.Document; import tech.easyflow.ai.entity.Document;
import tech.easyflow.ai.entity.DocumentCollection; import tech.easyflow.ai.entity.DocumentCollection;
import tech.easyflow.ai.entity.DocumentCollectionSplitParams; import tech.easyflow.ai.entity.DocumentCollectionSplitParams;
@@ -77,6 +83,12 @@ public class DocumentController extends BaseCurdController<DocumentService, Docu
@Autowired @Autowired
private ResourceAccessService resourceAccessService; private ResourceAccessService resourceAccessService;
@Autowired
private DocumentImportTaskStatusStreamService documentImportTaskStatusStreamService;
@Autowired
private DocumentImportBatchAppService documentImportBatchAppService;
@Value("${easyflow.storage.local.root:}") @Value("${easyflow.storage.local.root:}")
private String fileUploadPath; private String fileUploadPath;
@@ -87,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")
@@ -96,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); onRemoveAfter(ids);
} }
boolean success = service.removeById(id);
onRemoveAfter(ids);
return Result.ok(success); return Result.ok(success);
} }
@@ -136,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);
} }
@@ -233,6 +259,215 @@ public class DocumentController extends BaseCurdController<DocumentService, Docu
return documentService.commitImport(request); return documentService.commitImport(request);
} }
@PostMapping("import/task/create")
@SaCheckPermission("/api/v1/documentCollection/save")
public Result<DocumentImportDtos.TaskCreateResponse> createImportTask(@JsonBody DocumentImportDtos.TaskCreateRequest request) {
if (request.getKnowledgeId() == null) {
throw new BusinessException("知识库id不能为空");
}
getDocumentCollection(request.getKnowledgeId().toString(), ResourceAction.MANAGE, "无权限管理知识库");
return documentService.createImportTask(request);
}
@GetMapping("import/task/detail")
@SaCheckPermission("/api/v1/documentCollection/query")
public Result<DocumentImportDtos.TaskDetailResponse> getImportTaskDetail(@RequestParam BigInteger taskId) {
Result<DocumentImportDtos.TaskDetailResponse> result = documentService.getImportTaskDetail(taskId);
if (result.getData() != null && result.getData().getKnowledgeId() != null) {
getDocumentCollection(result.getData().getKnowledgeId().toString(), ResourceAction.READ, "无权限访问知识库");
}
return result;
}
/**
* 订阅知识库文档任务状态流。
*
* @param knowledgeId 知识库 ID
* @return SSE 推送连接
*/
@PostMapping(value = "import/task/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
@SaCheckPermission("/api/v1/documentCollection/query")
public SseEmitter streamImportTask(@JsonBody(value = "knowledgeId", required = true) BigInteger knowledgeId) {
getDocumentCollection(knowledgeId.toString(), ResourceAction.READ, "无权限访问知识库");
return documentImportTaskStatusStreamService.subscribe(knowledgeId);
}
@PostMapping("import/task/preview")
@SaCheckPermission("/api/v1/documentCollection/save")
public Result<DocumentImportDtos.PreviewResponse> previewImportTask(@JsonBody DocumentImportDtos.PreviewRequest request) {
if (request.getKnowledgeId() == null) {
throw new BusinessException("知识库id不能为空");
}
getDocumentCollection(request.getKnowledgeId().toString(), ResourceAction.MANAGE, "无权限管理知识库");
return documentService.previewImportTask(request);
}
@PostMapping("import/task/startIndex")
@SaCheckPermission("/api/v1/documentCollection/save")
public Result<DocumentImportDtos.TaskStartIndexResponse> startIndexTask(@JsonBody DocumentImportDtos.TaskStartIndexRequest request) {
if (request.getKnowledgeId() == null) {
throw new BusinessException("知识库id不能为空");
}
getDocumentCollection(request.getKnowledgeId().toString(), ResourceAction.MANAGE, "无权限管理知识库");
return documentService.startIndexTask(request);
}
@PostMapping("import/task/retryParse")
@SaCheckPermission("/api/v1/documentCollection/save")
public Result<DocumentImportDtos.TaskStartIndexResponse> retryParseTask(@JsonBody DocumentImportDtos.TaskRetryRequest request) {
if (request.getKnowledgeId() == null) {
throw new BusinessException("知识库id不能为空");
}
getDocumentCollection(request.getKnowledgeId().toString(), ResourceAction.MANAGE, "无权限管理知识库");
return documentService.retryParseTask(request);
}
@PostMapping("import/task/retryIndex")
@SaCheckPermission("/api/v1/documentCollection/save")
public Result<DocumentImportDtos.TaskStartIndexResponse> retryIndexTask(@JsonBody DocumentImportDtos.TaskRetryRequest request) {
if (request.getKnowledgeId() == null) {
throw new BusinessException("知识库id不能为空");
}
getDocumentCollection(request.getKnowledgeId().toString(), ResourceAction.MANAGE, "无权限管理知识库");
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

@@ -0,0 +1,240 @@
package tech.easyflow.admin.controller.ai;
import cn.dev33.satoken.annotation.SaCheckPermission;
import jakarta.servlet.http.HttpServletRequest;
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.dto.KnowledgeShareApiGrantRequest;
import tech.easyflow.ai.entity.DocumentCollection;
import tech.easyflow.ai.enums.KnowledgeShareActionScope;
import tech.easyflow.ai.service.DocumentCollectionService;
import tech.easyflow.ai.service.KnowledgeShareAuditService;
import tech.easyflow.ai.service.KnowledgeSharePermissionService;
import tech.easyflow.ai.service.KnowledgeShareService;
import tech.easyflow.ai.vo.KnowledgeShareUrlCreateResult;
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/knowledgeShare")
public class KnowledgeShareController {
@Resource
private KnowledgeShareService knowledgeShareService;
@Resource
private KnowledgeSharePermissionService knowledgeSharePermissionService;
@Resource
private KnowledgeShareAuditService knowledgeShareAuditService;
@Resource
private ResourceAccessService resourceAccessService;
@Resource
private DocumentCollectionService documentCollectionService;
/**
* 创建 URL 分享。
*
* @param request HTTP 请求
* @param knowledgeId 知识库 ID
* @return 创建结果
*/
@PostMapping("/url/create")
@SaCheckPermission("/api/v1/documentCollection/save")
public Result<KnowledgeShareUrlCreateResult> createUrlShare(HttpServletRequest request, @JsonBody("knowledgeId") BigInteger knowledgeId) {
assertManagePermission(knowledgeId);
LoginAccount loginAccount = SaTokenUtil.getLoginAccount();
KnowledgeShareUrlCreateResult result = knowledgeShareService.createUrlShare(
knowledgeId,
loginAccount.getTenantId(),
loginAccount.getDeptId(),
loginAccount.getId(),
buildShareBaseUrl(request),
KnowledgeShareActionScope.defaultUrlScopes()
);
knowledgeShareAuditService.log(
loginAccount.getId(),
"创建知识库 URL 分享",
"KNOWLEDGE_SHARE_CREATE",
request.getRequestURI(),
Map.of("knowledgeId", knowledgeId, "shareId", result.getId())
);
return Result.ok(result);
}
/**
* 为系统访问令牌配置知识库 API 分享授权。
*
* @param request 授权请求
* @return 结果
*/
@PostMapping("/api/grant")
@SaCheckPermission("/api/v1/documentCollection/save")
public Result<Void> grantApiShare(@JsonBody KnowledgeShareApiGrantRequest request) {
assertManagePermission(request.getKnowledgeId());
knowledgeSharePermissionService.grantApiShare(
request.getApiKeyId(),
request.getKnowledgeId(),
request.getActionScopes()
);
LoginAccount loginAccount = SaTokenUtil.getLoginAccount();
knowledgeShareAuditService.log(
loginAccount.getId(),
"配置知识库 API 分享授权",
"KNOWLEDGE_API_SHARE_GRANT",
"/api/v1/knowledgeShare/api/grant",
Map.of(
"knowledgeId", request.getKnowledgeId(),
"apiKeyId", request.getApiKeyId(),
"actionScopes", request.getActionScopes()
)
);
return Result.ok();
}
private void assertManagePermission(BigInteger knowledgeId) {
DocumentCollection knowledge = documentCollectionService.getById(knowledgeId);
if (knowledge == null) {
throw new BusinessException("知识库不存在");
}
resourceAccessService.assertAccess(
CategoryResourceType.KNOWLEDGE,
knowledge,
ResourceAction.MANAGE,
"无权限管理知识库"
);
}
private String buildShareBaseUrl(HttpServletRequest request) {
String referer = RequestUtil.getReferer(request);
String refererBaseUrl = extractFrontendBaseUrl(referer);
if (refererBaseUrl != null) {
return refererBaseUrl + "/share/knowledge";
}
String forwardedOrigin = buildForwardedOrigin(request);
if (forwardedOrigin != null) {
return forwardedOrigin + normalizeBasePath(firstHeaderValue(request.getHeader("X-Forwarded-Prefix"))) + "/share/knowledge";
}
String origin = normalizeOrigin(request.getHeader("Origin"));
if (origin != null) {
return origin + normalizeBasePath(request.getContextPath()) + "/share/knowledge";
}
StringBuilder builder = new StringBuilder();
builder.append(request.getScheme()).append("://").append(request.getServerName());
if (request.getServerPort() != 80 && request.getServerPort() != 443) {
builder.append(':').append(request.getServerPort());
}
builder.append(normalizeBasePath(request.getContextPath())).append("/share/knowledge");
return builder.toString();
}
private String extractFrontendBaseUrl(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;
}
String origin = extractOrigin(url);
if (origin == null) {
return null;
}
return origin + inferFrontendBasePath(uri.getPath());
} catch (URISyntaxException e) {
return null;
}
}
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 "";
}
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;
}
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);
}
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;
}
}
private String normalizeOrigin(String origin) {
return extractOrigin(origin);
}
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,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) {
@@ -80,4 +117,4 @@ public class McpController extends BaseCurdController<McpService, Mcp> {
return Result.ok(service.pageTools(mcpPage)); return Result.ok(service.pageTools(mcpPage));
} }
} }

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

@@ -1,9 +1,11 @@
package tech.easyflow.admin.controller.ai; package tech.easyflow.admin.controller.ai;
import cn.dev33.satoken.annotation.SaCheckPermission;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import tech.easyflow.ai.entity.PluginCategory; import tech.easyflow.ai.entity.PluginCategory;
import tech.easyflow.ai.entity.PluginCategoryMapping; import tech.easyflow.ai.entity.PluginCategoryMapping;
import tech.easyflow.ai.service.PluginCategoryMappingService; import tech.easyflow.ai.service.PluginCategoryMappingService;
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.BaseCurdController;
import tech.easyflow.common.web.jsonbody.JsonBody; import tech.easyflow.common.web.jsonbody.JsonBody;
@@ -21,6 +23,7 @@ import java.util.List;
*/ */
@RestController @RestController
@RequestMapping("/api/v1/pluginCategoryMapping") @RequestMapping("/api/v1/pluginCategoryMapping")
@UsePermission(moduleName = "/api/v1/plugin")
public class PluginCategoryMappingController extends BaseCurdController<PluginCategoryMappingService, PluginCategoryMapping> { public class PluginCategoryMappingController extends BaseCurdController<PluginCategoryMappingService, PluginCategoryMapping> {
public PluginCategoryMappingController(PluginCategoryMappingService service) { public PluginCategoryMappingController(PluginCategoryMappingService service) {
super(service); super(service);
@@ -30,6 +33,7 @@ public class PluginCategoryMappingController extends BaseCurdController<PluginCa
private PluginCategoryMappingService relationService; private PluginCategoryMappingService relationService;
@PostMapping("/updateRelation") @PostMapping("/updateRelation")
@SaCheckPermission("/api/v1/plugin/save")
public Result<Boolean> updateRelation( public Result<Boolean> updateRelation(
@JsonBody(value="pluginId") BigInteger pluginId, @JsonBody(value="pluginId") BigInteger pluginId,
@JsonBody(value="categoryIds") ArrayList<BigInteger> categoryIds @JsonBody(value="categoryIds") ArrayList<BigInteger> categoryIds
@@ -42,4 +46,4 @@ public class PluginCategoryMappingController extends BaseCurdController<PluginCa
){ ){
return Result.ok(relationService.getPluginCategories(pluginId)); return Result.ok(relationService.getPluginCategories(pluginId));
} }
} }

View File

@@ -2,25 +2,39 @@ 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;
import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PostMapping;
import tech.easyflow.admin.controller.ai.support.AiResourceCreatorNameSupport;
import tech.easyflow.ai.entity.Model; import tech.easyflow.ai.entity.Model;
import tech.easyflow.ai.entity.Plugin; import tech.easyflow.ai.entity.Plugin;
import tech.easyflow.ai.entity.Workflow;
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.service.WorkflowService;
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.ai.service.PluginService; import tech.easyflow.ai.service.PluginService;
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.web.jsonbody.JsonBody; import tech.easyflow.common.web.jsonbody.JsonBody;
import tech.easyflow.system.enums.CategoryResourceType;
import tech.easyflow.system.enums.ResourceAction;
import tech.easyflow.system.entity.vo.RoleCategoryAccessSnapshot; import tech.easyflow.system.entity.vo.RoleCategoryAccessSnapshot;
import tech.easyflow.system.service.CategoryPermissionService; import tech.easyflow.system.service.CategoryPermissionService;
import tech.easyflow.system.service.ResourceAccessService;
import javax.annotation.Resource; import javax.annotation.Resource;
import java.math.BigInteger; import java.math.BigInteger;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List; import java.util.List;
import java.util.Set; import java.util.Set;
@@ -35,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
@@ -47,6 +77,16 @@ public class PluginController extends BaseCurdController<PluginService, Plugin>
private PluginVisibilityService pluginVisibilityService; private PluginVisibilityService pluginVisibilityService;
@Resource @Resource
private ModelService modelService; private ModelService modelService;
@Resource
private WorkflowVisibilityQueryHelper workflowVisibilityQueryHelper;
@Resource
private WorkflowService workflowService;
@Resource
private ResourceAccessService resourceAccessService;
@Resource
private WorkflowPluginSnapshotResolver workflowPluginSnapshotResolver;
@Resource
private AiResourceCreatorNameSupport aiResourceCreatorNameSupport;
@Override @Override
protected Result<?> onSaveOrUpdateBefore(Plugin entity, boolean isSave) { protected Result<?> onSaveOrUpdateBefore(Plugin entity, boolean isSave) {
@@ -67,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));
} }
@@ -79,12 +124,28 @@ public class PluginController extends BaseCurdController<PluginService, Plugin>
public Result<List<Plugin>> getList(){ public Result<List<Plugin>> getList(){
QueryWrapper queryWrapper = QueryWrapper.create().select(); QueryWrapper queryWrapper = QueryWrapper.create().select();
applyCategoryPermission(queryWrapper); applyCategoryPermission(queryWrapper);
return Result.ok(service.getMapper().selectListByQuery(queryWrapper)); List<Plugin> plugins = service.getMapper().selectListWithRelationsByQuery(queryWrapper);
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;
} }
@@ -97,10 +158,34 @@ 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 {
return 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) {
aiResourceCreatorNameSupport.fillPluginCreatorNames(result.getData().getRecords());
}
return result;
} }
} }
@GetMapping("/workflowCandidates")
@SaCheckPermission("/api/v1/plugin/query")
public Result<List<Workflow>> workflowCandidates(String keyword) {
QueryWrapper queryWrapper = QueryWrapper.create();
workflowVisibilityQueryHelper.applyReadableAccess(queryWrapper);
queryWrapper.eq("publish_status", tech.easyflow.ai.enums.PublishStatus.PUBLISHED.getCode());
if (keyword != null && !keyword.isBlank()) {
queryWrapper.and(buildLiteralContainsCondition(keyword, new QueryColumn("title")));
}
queryWrapper.orderBy("modified desc");
LoginAccount loginAccount = SaTokenUtil.getLoginAccount();
List<Workflow> workflows = workflowService.list(queryWrapper).stream()
.filter(workflow -> canBindWorkflowCandidate(workflow, loginAccount))
.collect(java.util.stream.Collectors.toCollection(ArrayList::new));
return Result.ok(workflows);
}
@GetMapping("/modelList") @GetMapping("/modelList")
@SaCheckPermission("/api/v1/plugin/query") @SaCheckPermission("/api/v1/plugin/query")
public Result<List<Model>> modelList(Model entity, Boolean asTree, String sortKey, String sortType) { public Result<List<Model>> modelList(Model entity, Boolean asTree, String sortKey, String sortType) {
@@ -110,14 +195,25 @@ public class PluginController extends BaseCurdController<PluginService, Plugin>
@Override @Override
protected Page<Plugin> queryPage(Page<Plugin> page, QueryWrapper queryWrapper) { protected Page<Plugin> queryPage(Page<Plugin> page, QueryWrapper queryWrapper) {
applyCategoryPermission(queryWrapper); applyCategoryPermission(queryWrapper);
return service.getMapper().paginateWithRelations(page, queryWrapper); List<Plugin> totalList = service.getMapper().selectListWithRelationsByQuery(queryWrapper);
boolean availableOnly = isAvailableOnly();
List<Plugin> prepared = pluginService.preparePluginsForCurrentUser(totalList, !availableOnly, availableOnly);
aiResourceCreatorNameSupport.fillPluginCreatorNames(prepared);
long total = prepared.size();
int fromIndex = Math.max(0, Math.toIntExact((page.getPageNumber() - 1) * page.getPageSize()));
if (fromIndex >= prepared.size()) {
return new Page<>(Collections.emptyList(), page.getPageNumber(), page.getPageSize(), total);
}
int toIndex = Math.min(prepared.size(), Math.toIntExact(fromIndex + page.getPageSize()));
return new Page<>(prepared.subList(fromIndex, toIndex), page.getPageNumber(), page.getPageSize(), total);
} }
@Override @Override
public Result<Plugin> detail(String id) { public Result<Plugin> detail(String id) {
Plugin plugin = service.getById(id); Plugin plugin = service.getMapper().selectOneWithRelationsById(id);
if (plugin != null) { if (plugin != null) {
pluginVisibilityService.assertPluginVisible(plugin.getCreatedBy(), plugin.getId(), "无权限访问插件"); pluginVisibilityService.assertPluginVisible(plugin.getCreatedBy(), plugin.getId(), "无权限访问插件");
pluginService.preparePluginForCurrentUser(plugin);
} }
return Result.ok(plugin); return Result.ok(plugin);
} }
@@ -138,4 +234,30 @@ public class PluginController extends BaseCurdController<PluginService, Plugin>
} }
queryWrapper.and(PLUGIN.CREATED_BY.eq(access.getAccountIdAsLong()).or(PLUGIN.ID.in(pluginIds))); queryWrapper.and(PLUGIN.CREATED_BY.eq(access.getAccountIdAsLong()).or(PLUGIN.ID.in(pluginIds)));
} }
private boolean isAvailableOnly() {
HttpServletRequest request = currentHttpRequest();
return request != null && "true".equalsIgnoreCase(request.getParameter("availableOnly"));
}
private HttpServletRequest currentHttpRequest() {
org.springframework.web.context.request.ServletRequestAttributes attributes =
(org.springframework.web.context.request.ServletRequestAttributes)
org.springframework.web.context.request.RequestContextHolder.getRequestAttributes();
return attributes == null ? null : attributes.getRequest();
}
private boolean canBindWorkflowCandidate(Workflow workflow, LoginAccount loginAccount) {
if (workflow == null || loginAccount == null || loginAccount.getId() == null) {
return false;
}
if (workflow.getPublishedSnapshotJson() == null || workflow.getPublishedSnapshotJson().isEmpty()) {
return false;
}
Workflow publishedWorkflow = workflowService.toPublishedView(workflow);
if (!workflowPluginSnapshotResolver.isSupportedForWorkflowPlugin(publishedWorkflow)) {
return false;
}
return resourceAccessService.canAccess(loginAccount, CategoryResourceType.WORKFLOW, workflow, ResourceAction.USE);
}
} }

View File

@@ -1,30 +1,55 @@
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 cn.dev33.satoken.stp.StpUtil;
import cn.hutool.core.util.IdUtil; import cn.hutool.core.util.IdUtil;
import cn.hutool.core.util.StrUtil; import cn.hutool.core.util.StrUtil;
import com.alibaba.fastjson2.JSON; import com.alibaba.fastjson2.JSON;
import com.alibaba.fastjson2.JSONArray; import com.alibaba.fastjson2.JSONArray;
import com.alibaba.fastjson2.JSONObject; import com.alibaba.fastjson2.JSONObject;
import com.easyagents.flow.core.chain.runtime.ChainExecutor;
import com.mybatisflex.core.query.QueryWrapper; import com.mybatisflex.core.query.QueryWrapper;
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.entity.BotPlugin; import tech.easyflow.ai.easyagentsflow.entity.ChainInfo;
import tech.easyflow.ai.easyagentsflow.entity.NodeInfo;
import tech.easyflow.ai.easyagentsflow.entity.WorkflowCheckStage;
import tech.easyflow.ai.easyagentsflow.service.TinyFlowService;
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.entity.Plugin;
import tech.easyflow.ai.entity.PluginItem; import tech.easyflow.ai.entity.PluginItem;
import tech.easyflow.ai.service.BotPluginService; import tech.easyflow.ai.entity.Workflow;
import tech.easyflow.ai.entity.WorkflowExecResult;
import tech.easyflow.ai.enums.PluginType;
import tech.easyflow.ai.plugin.workflow.snapshot.WorkflowPluginSnapshotResolver;
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.WorkflowExecResultService;
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.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; import java.math.BigInteger;
import java.util.Collection; import java.util.Collection;
import java.util.HashMap;
import java.util.LinkedHashSet;
import java.util.List; import java.util.List;
import java.util.Map;
import java.util.Set;
/** /**
* 控制层。 * 控制层。
@@ -36,15 +61,48 @@ import java.util.List;
@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
private PluginService pluginService;
@Resource
private PluginVisibilityService pluginVisibilityService;
@Resource
private WorkflowPluginSnapshotResolver workflowPluginSnapshotResolver;
@Resource
private WorkflowService workflowService;
@Resource
private WorkflowExecResultService workflowExecResultService;
@Resource
private ChainExecutor chainExecutor;
@Resource
private TinyFlowService tinyFlowService;
@Resource
private WorkflowCheckService workflowCheckService;
@Resource
private WorkflowResumeService workflowResumeService;
@PostMapping("/tool/save") @PostMapping("/tool/save")
@SaCheckPermission("/api/v1/plugin/save") @SaCheckPermission("/api/v1/plugin/save")
@@ -60,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) {
@@ -87,8 +132,18 @@ public class PluginItemController extends BaseCurdController<PluginItemService,
if (record == null) { if (record == null) {
return Result.ok(nodeData); return Result.ok(nodeData);
} }
Plugin plugin = pluginService.getById(record.getPluginId());
nodeData.put("pluginId", record.getId().toString()); nodeData.put("pluginId", record.getId().toString());
nodeData.put("pluginName", record.getName()); nodeData.put("pluginName", record.getName());
if (plugin != null) {
plugin = pluginService.preparePluginForCurrentUser(plugin);
nodeData.put("pluginType", plugin.getType());
nodeData.put("workflowId", plugin.getWorkflowId());
nodeData.put("workflowTitle", plugin.getWorkflowTitle());
nodeData.put("available", plugin.getAvailable());
nodeData.put("reasonCode", plugin.getReasonCode());
nodeData.put("reasonMessage", plugin.getReasonMessage());
}
JSONArray parameters = new JSONArray(); JSONArray parameters = new JSONArray();
JSONArray outputDefs = new JSONArray(); JSONArray outputDefs = new JSONArray();
@@ -104,6 +159,7 @@ public class PluginItemController extends BaseCurdController<PluginItemService,
handleArray(array); handleArray(array);
outputDefs = array; outputDefs = array;
} }
nodeData.put("schemaHash", resolveSchemaHash(record, plugin));
nodeData.put("parameters", parameters); nodeData.put("parameters", parameters);
nodeData.put("outputDefs", outputDefs); nodeData.put("outputDefs", outputDefs);
return Result.ok(nodeData); return Result.ok(nodeData);
@@ -119,6 +175,95 @@ public class PluginItemController extends BaseCurdController<PluginItemService,
return pluginItemService.pluginToolTest(inputData, pluginToolId); return pluginItemService.pluginToolTest(inputData, pluginToolId);
} }
/**
* 工作流插件异步试运行。
*
* @param inputData 输入参数 JSON
* @param pluginToolId 插件工具 ID
* @return 执行 ID
*/
@PostMapping("/testAsync")
@SaCheckPermission("/api/v1/plugin/query")
public Result<String> pluginToolTestAsync(@JsonBody(value = "inputData", required = true) String inputData,
@JsonBody(value = "pluginToolId", required = true) BigInteger pluginToolId) {
PluginItem pluginItem = pluginItemService.getById(pluginToolId);
Plugin plugin = requireWorkflowPlugin(pluginItem);
Plugin preparedPlugin = pluginService.preparePluginForCurrentUser(plugin);
if (Boolean.FALSE.equals(preparedPlugin.getAvailable())) {
throw new BusinessException(preparedPlugin.getReasonMessage());
}
Workflow workflow = workflowService.getPublishedById(preparedPlugin.getWorkflowId());
if (workflow == null) {
throw new BusinessException("未找到已发布工作流");
}
workflowCheckService.checkOrThrow(workflow.getContent(), WorkflowCheckStage.PRE_EXECUTE, workflow.getId());
Map<String, Object> variables = JSON.parseObject(inputData, Map.class);
if (variables == null) {
variables = new HashMap<>();
}
if (StpUtil.isLogin()) {
variables.put(Constants.LOGIN_USER_KEY, SaTokenUtil.getLoginAccount());
}
String executeId = chainExecutor.executeAsync(
PublishedWorkflowDefinitionIds.published(String.valueOf(workflow.getId())),
variables
);
return Result.ok(executeId);
}
/**
* 获取工作流插件试运行状态。
*
* @param executeId 执行 ID
* @param nodes 节点列表
* @return 链路状态
*/
@PostMapping("/testChainStatus")
@SaCheckPermission("/api/v1/plugin/query")
public Result<ChainInfo> pluginToolTestChainStatus(@JsonBody(value = "executeId", required = true) String executeId,
@JsonBody("nodes") List<NodeInfo> nodes) {
assertPluginTestExecutionOwnership(executeId);
return Result.ok(tinyFlowService.getChainStatus(executeId, nodes));
}
/**
* 恢复工作流插件试运行。
*
* @param executeId 执行 ID
* @param confirmParams 恢复参数
* @return 空结果
*/
@PostMapping("/testResume")
@SaCheckPermission("/api/v1/plugin/query")
public Result<Void> pluginToolTestResume(@JsonBody(value = "executeId", required = true) String executeId,
@JsonBody("confirmParams") Map<String, Object> confirmParams) {
assertPluginTestExecutionOwnership(executeId);
workflowResumeService.resume(executeId, confirmParams);
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;
@@ -134,17 +279,98 @@ public class PluginItemController extends BaseCurdController<PluginItemService,
} }
} }
private String resolveSchemaHash(PluginItem record, Plugin plugin) {
if (record == null) {
return null;
}
if (StrUtil.isNotBlank(record.getSchemaHash())) {
return record.getSchemaHash();
}
if (plugin == null || !PluginType.isWorkflow(plugin.getType()) || plugin.getWorkflowId() == null) {
return null;
}
tech.easyflow.ai.entity.Workflow workflow = workflowService.getPublishedById(plugin.getWorkflowId());
if (workflow == null) {
return null;
}
return workflowPluginSnapshotResolver.resolveSchemaHash(workflow);
}
private Plugin requireWorkflowPlugin(PluginItem pluginItem) {
if (pluginItem == null) {
throw new BusinessException("插件工具不存在");
}
Plugin plugin = pluginService.getById(pluginItem.getPluginId());
if (plugin == null) {
throw new BusinessException("插件不存在");
}
if (!PluginType.isWorkflow(plugin.getType())) {
throw new BusinessException("当前工具不是工作流插件");
}
if (plugin.getWorkflowId() == null) {
throw new BusinessException("插件未绑定工作流");
}
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请先取消关联"); uniquePluginItemIds.add(new BigInteger(String.valueOf(id)));
}
} catch (NumberFormatException exception) {
throw new BusinessException("插件工具 ID 不合法");
} }
// 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, "工作流插件工具由系统自动维护,不支持删除");
}
}
agentResourceReferenceService.assertPluginItemsUnused(List.copyOf(uniquePluginItemIds));
return null; return null;
} }
} }

View File

@@ -0,0 +1,63 @@
package tech.easyflow.admin.controller.ai;
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.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import tech.easyflow.chatlog.domain.dto.ChatMessageRecord;
import tech.easyflow.chatlog.domain.dto.PublicChatSessionRestoreResult;
import tech.easyflow.chatlog.service.PublicChatSessionRestoreService;
import tech.easyflow.common.domain.Result;
import tech.easyflow.common.entity.LoginAccount;
import tech.easyflow.common.satoken.util.SaTokenUtil;
import tech.easyflow.common.web.jsonbody.JsonBody;
import java.math.BigInteger;
import java.util.List;
@RestController
@RequestMapping("/api/v1/public-chat")
public class PublicChatSessionController {
private final PublicChatSessionRestoreService publicChatSessionRestoreService;
public PublicChatSessionController(PublicChatSessionRestoreService publicChatSessionRestoreService) {
this.publicChatSessionRestoreService = publicChatSessionRestoreService;
}
@GetMapping("/session/restore")
public Result<PublicChatSessionRestoreResult> restoreSession(BigInteger botId,
BigInteger conversationId,
Integer limit) {
LoginAccount account = SaTokenUtil.getLoginAccount();
BigInteger userId = account == null ? null : account.getId();
PublicChatSessionRestoreResult result = publicChatSessionRestoreService.restoreSession(
userId,
botId,
conversationId,
limit
);
return Result.ok(result);
}
@GetMapping("/session/{sessionId}/rounds/{roundId}/variants")
public Result<List<ChatMessageRecord>> listRoundVariants(@PathVariable BigInteger sessionId,
@PathVariable BigInteger roundId,
BigInteger botId) {
LoginAccount account = SaTokenUtil.getLoginAccount();
BigInteger userId = account == null ? null : account.getId();
return Result.ok(publicChatSessionRestoreService.listVariants(userId, botId, sessionId, roundId));
}
@PostMapping("/session/{sessionId}/rounds/{roundId}/selectVariant")
public Result<ChatMessageRecord> selectRoundVariant(@PathVariable BigInteger sessionId,
@PathVariable BigInteger roundId,
BigInteger botId,
@JsonBody(value = "variantIndex", required = true) Integer variantIndex) {
LoginAccount account = SaTokenUtil.getLoginAccount();
BigInteger userId = account == null ? null : account.getId();
BigInteger operatorId = account == null ? BigInteger.ZERO : account.getId();
return Result.ok(publicChatSessionRestoreService.selectVariant(userId, botId, sessionId, roundId, variantIndex, operatorId));
}
}

View File

@@ -4,14 +4,19 @@ import cn.hutool.core.io.FileTypeUtil;
import cn.hutool.http.HttpUtil; import cn.hutool.http.HttpUtil;
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 org.springframework.util.StringUtils;
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.admin.model.ai.ResourcePreviewVo;
import tech.easyflow.ai.entity.Resource; import tech.easyflow.ai.entity.Resource;
import tech.easyflow.ai.service.ResourceService; import tech.easyflow.ai.service.ResourceService;
import tech.easyflow.ai.utils.DocUtil;
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.web.controller.BaseCurdController; 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.entity.vo.RoleCategoryAccessSnapshot;
import tech.easyflow.system.service.CategoryPermissionService; import tech.easyflow.system.service.CategoryPermissionService;
@@ -31,6 +36,8 @@ import static tech.easyflow.ai.entity.table.ResourceTableDef.RESOURCE;
@RestController @RestController
@RequestMapping("/api/v1/resource") @RequestMapping("/api/v1/resource")
public class ResourceController extends BaseCurdController<ResourceService, Resource> { public class ResourceController extends BaseCurdController<ResourceService, Resource> {
private static final int RESOURCE_PREVIEW_CONTENT_LIMIT = 20_000;
@javax.annotation.Resource @javax.annotation.Resource
private CategoryPermissionService categoryPermissionService; private CategoryPermissionService categoryPermissionService;
@@ -38,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();
@@ -79,6 +96,26 @@ public class ResourceController extends BaseCurdController<ResourceService, Reso
return Result.ok(resource); return Result.ok(resource);
} }
/**
* 获取素材预览内容。
*
* @param id 素材ID
* @return 预览文本
*/
@GetMapping("/previewContent")
public Result<ResourcePreviewVo> previewContent(String id) {
Resource resource = service.getById(id);
if (resource == null) {
throw new BusinessException("素材不存在");
}
categoryPermissionService.assertCategoryResourceVisible("RESOURCE", resource.getCreatedBy(), resource.getCategoryId(), "无权限访问素材");
byte[] bytes = DocUtil.downloadFile(resource.getResourceUrl());
String suffix = resolvePreviewSuffix(resource, bytes);
String content = DocUtil.readPreviewContent(suffix, new ByteArrayInputStream(bytes));
return Result.ok(buildPreviewVo(content));
}
private void applyCategoryPermission(QueryWrapper queryWrapper) { private void applyCategoryPermission(QueryWrapper queryWrapper) {
RoleCategoryAccessSnapshot access = categoryPermissionService.getCurrentAccess("RESOURCE"); RoleCategoryAccessSnapshot access = categoryPermissionService.getCurrentAccess("RESOURCE");
if (!access.isRestricted()) { if (!access.isRestricted()) {
@@ -90,4 +127,37 @@ public class ResourceController extends BaseCurdController<ResourceService, Reso
} }
queryWrapper.and(RESOURCE.CREATED_BY.eq(access.getAccountId()).or(RESOURCE.CATEGORY_ID.in(access.getCategoryIds()))); queryWrapper.and(RESOURCE.CREATED_BY.eq(access.getAccountId()).or(RESOURCE.CATEGORY_ID.in(access.getCategoryIds())));
} }
/**
* 解析用于预览的文件后缀。
*
* @param resource 素材实体
* @param bytes 文件字节
* @return 标准化后的文件后缀
*/
private String resolvePreviewSuffix(Resource resource, byte[] bytes) {
if (StringUtils.hasText(resource.getSuffix())) {
return DocUtil.normalizeSuffix(resource.getSuffix());
}
String detectedSuffix = FileTypeUtil.getType(new ByteArrayInputStream(bytes), resource.getResourceUrl());
if (!StringUtils.hasText(detectedSuffix)) {
throw new BusinessException("无法识别当前素材文件类型");
}
return DocUtil.normalizeSuffix(detectedSuffix);
}
/**
* 构建预览返回对象,并在服务端截断超长内容。
*
* @param content 原始内容
* @return 预览内容对象
*/
private ResourcePreviewVo buildPreviewVo(String content) {
String safeContent = content == null ? "" : content;
boolean truncated = safeContent.length() > RESOURCE_PREVIEW_CONTENT_LIMIT;
ResourcePreviewVo vo = new ResourcePreviewVo();
vo.setContent(truncated ? safeContent.substring(0, RESOURCE_PREVIEW_CONTENT_LIMIT) : safeContent);
vo.setTruncated(truncated);
return vo;
}
} }

View File

@@ -0,0 +1,990 @@
package tech.easyflow.admin.controller.ai;
import cn.hutool.core.io.IoUtil;
import com.mybatisflex.core.paginate.Page;
import com.mybatisflex.core.query.QueryColumn;
import com.mybatisflex.core.query.QueryWrapper;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.core.io.ClassPathResource;
import org.springframework.http.MediaType;
import org.springframework.util.StringUtils;
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.servlet.mvc.method.annotation.SseEmitter;
import org.springframework.web.multipart.MultipartFile;
import tech.easyflow.ai.documentimport.DocumentImportDtos;
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.KnowledgeSearchResultItem;
import tech.easyflow.ai.entity.Document;
import tech.easyflow.ai.entity.DocumentChunk;
import tech.easyflow.ai.entity.DocumentCollection;
import tech.easyflow.ai.entity.DocumentCollectionSplitParams;
import tech.easyflow.ai.entity.FaqCategory;
import tech.easyflow.ai.entity.FaqItem;
import tech.easyflow.ai.entity.Model;
import tech.easyflow.ai.enums.KnowledgeShareActionScope;
import tech.easyflow.ai.rag.KnowledgeRetrievalModes;
import tech.easyflow.ai.rag.KnowledgeRetrievalRequest;
import tech.easyflow.ai.service.DocumentChunkService;
import tech.easyflow.ai.service.DocumentCollectionService;
import tech.easyflow.ai.service.DocumentService;
import tech.easyflow.ai.service.FaqCategoryService;
import tech.easyflow.ai.service.FaqItemService;
import tech.easyflow.ai.service.KnowledgeEmbeddingService;
import tech.easyflow.ai.service.KnowledgeShareAuditService;
import tech.easyflow.ai.service.KnowledgeShareService;
import tech.easyflow.ai.service.ModelService;
import tech.easyflow.ai.vo.FaqImportResultVo;
import tech.easyflow.ai.vo.KnowledgeShareAuthContext;
import tech.easyflow.ai.vo.KnowledgeShareViewDetail;
import tech.easyflow.common.domain.Result;
import tech.easyflow.common.util.SearchKeywordUtil;
import tech.easyflow.common.filestorage.FileStorageService;
import tech.easyflow.common.vo.UploadResVo;
import tech.easyflow.common.web.exceptions.BusinessException;
import tech.easyflow.common.web.jsonbody.JsonBody;
import javax.annotation.Resource;
import java.io.InputStream;
import java.math.BigDecimal;
import java.math.BigInteger;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.text.SimpleDateFormat;
import java.util.Arrays;
import java.util.Date;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
/**
* 内部员工登录态下的知识库分享访问接口。
*/
@RestController
@RequestMapping("/api/v1/share/knowledge")
public class ShareKnowledgeController {
private static final long MAX_IMAGE_SIZE_BYTES = 20L * 1024L * 1024L;
private static final Set<String> ALLOWED_IMAGE_TYPES = new HashSet<>(Arrays.asList(
"image/jpeg",
"image/png",
"image/webp",
"image/gif"
));
@Resource
private KnowledgeShareService knowledgeShareService;
@Resource
private KnowledgeShareAuditService knowledgeShareAuditService;
@Resource
private DocumentCollectionService documentCollectionService;
@Resource
private DocumentService documentService;
@Resource
private DocumentChunkService documentChunkService;
@Resource
private ModelService modelService;
@Resource
private FaqItemService faqItemService;
@Resource
private FaqCategoryService faqCategoryService;
@Resource
private KnowledgeEmbeddingService knowledgeEmbeddingService;
@Resource(name = "default")
private FileStorageService fileStorageService;
@Resource
private DocumentImportTaskStatusStreamService documentImportTaskStatusStreamService;
/**
* 获取知识库详情。
*
* @param shareKey 分享访问密钥
* @return 知识库详情
*/
@GetMapping("/documentCollection/detail")
public Result<KnowledgeShareViewDetail> detail(@RequestParam String shareKey) {
KnowledgeShareAuthContext context = knowledgeShareService.assertUrlShareAccess(
shareKey,
null,
KnowledgeShareActionScope.VIEW.name()
);
audit(context, "访问知识库分享页", "KNOWLEDGE_SHARE_URL_ACCESS", false, auditDetail("knowledgeId", context.getKnowledge().getId()));
KnowledgeShareViewDetail detail = new KnowledgeShareViewDetail();
detail.setKnowledge(context.getKnowledge());
detail.setPermissionScopes(new java.util.ArrayList<String>(context.getShare().getPermissionScopeSet()));
return Result.ok(detail);
}
/**
* 获取模型列表。
*
* @param shareKey 分享访问密钥
* @param modelType 模型类型
* @return 模型列表
*/
@GetMapping("/documentCollection/modelList")
public Result<List<Model>> modelList(
@RequestParam String shareKey,
@RequestParam String modelType
) {
knowledgeShareService.assertUrlShareAccess(shareKey, null, KnowledgeShareActionScope.CONFIG_UPDATE.name());
Model entity = new Model();
entity.setModelType(modelType);
return Result.ok(modelService.listSelectableModels(entity, false, null, null));
}
/**
* 更新受限配置。
*
* @param request 更新请求
* @param shareKey 分享访问密钥
* @return 结果
*/
@PostMapping("/documentCollection/shareConfigUpdate")
public Result<Void> updateConfig(
@JsonBody KnowledgeShareLimitedConfigRequest request,
@RequestParam String shareKey
) {
KnowledgeShareAuthContext context = knowledgeShareService.assertUrlShareAccess(
shareKey,
request == null ? null : request.getKnowledgeId(),
KnowledgeShareActionScope.CONFIG_UPDATE.name()
);
BigInteger knowledgeId = resolveKnowledgeId(context, request == null ? null : request.getKnowledgeId());
DocumentCollection current = context.getKnowledge();
DocumentCollection update = new DocumentCollection();
update.setId(current.getId());
Map<String, Object> options = current.getOptions() == null
? new HashMap<>()
: new HashMap<>(current.getOptions());
if (request.getVectorEmbedModelId() != null) {
update.setVectorEmbedModelId(request.getVectorEmbedModelId());
options.put(DocumentCollection.KEY_CAN_UPDATE_EMBEDDING_MODEL, true);
Model model = modelService.getModelInstance(request.getVectorEmbedModelId());
if (model != null) {
update.setDimensionOfVectorModel(Model.getEmbeddingDimension(model.toEmbeddingModel()));
}
}
if (request.getRerankModelId() != null || current.getRerankModelId() != null) {
update.setRerankModelId(request.getRerankModelId());
}
if (request.getRerankEnable() != null) {
options.put(DocumentCollection.KEY_RERANK_ENABLE, request.getRerankEnable());
}
if (request.getDocRecallMaxNum() != null) {
options.put(DocumentCollection.KEY_DOC_RECALL_MAX_NUM, request.getDocRecallMaxNum());
}
if (request.getSimThreshold() != null) {
options.put(DocumentCollection.KEY_SIMILARITY_THRESHOLD, BigDecimal.valueOf(request.getSimThreshold()));
}
update.setOptions(options);
documentCollectionService.updateById(update);
if (Boolean.TRUE.equals(request.getRebuildVectors())) {
knowledgeEmbeddingService.rebuildKnowledgeVectors(knowledgeId);
}
audit(context, "更新知识库分享受限配置", "KNOWLEDGE_SHARE_URL_WRITE", true, auditDetail("knowledgeId", knowledgeId));
return Result.ok();
}
/**
* 检索知识库。
*
* @param shareKey 分享访问密钥
* @param keyword 检索关键词
* @param retrievalMode 检索模式
* @return 检索结果
*/
@GetMapping("/documentCollection/search")
public Result<List<KnowledgeSearchResultItem>> search(
@RequestParam String shareKey,
@RequestParam String keyword,
@RequestParam(required = false) String retrievalMode
) {
KnowledgeShareAuthContext context = knowledgeShareService.assertUrlShareAccess(
shareKey,
null,
KnowledgeShareActionScope.SEARCH.name()
);
KnowledgeRetrievalRequest request = new KnowledgeRetrievalRequest();
request.setKnowledgeId(context.getKnowledge().getId());
request.setQuery(keyword);
request.setRetrievalMode(KnowledgeRetrievalModes.parse(retrievalMode));
request.setCallerType("SHARE_URL");
request.setCallerId(String.valueOf(context.getShare().getId()));
return Result.ok(toKnowledgeSearchResult(documentCollectionService.search(request)));
}
/**
* 文档分页。
*/
@GetMapping("/document/documentList")
public Result<Page<Document>> documentPage(
@RequestParam String shareKey,
@RequestParam(required = false) String title,
@RequestParam(defaultValue = "10") int pageSize,
@RequestParam(defaultValue = "1") int pageNumber
) {
KnowledgeShareAuthContext context = knowledgeShareService.assertUrlShareAccess(
shareKey,
null,
KnowledgeShareActionScope.VIEW.name()
);
return Result.ok(documentService.getDocumentList(context.getKnowledge().getId().toString(), pageSize, pageNumber, title));
}
/**
* 订阅分享知识库的文档任务状态流。
*
* @param shareKey 分享访问密钥
* @param knowledgeId 知识库 ID
* @return SSE 推送连接
*/
@PostMapping(value = "/document/import/task/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public SseEmitter streamDocumentTask(
@RequestParam String shareKey,
@JsonBody(value = "knowledgeId", required = true) BigInteger knowledgeId
) {
KnowledgeShareAuthContext context = knowledgeShareService.assertUrlShareAccess(
shareKey,
knowledgeId,
KnowledgeShareActionScope.VIEW.name()
);
return documentImportTaskStatusStreamService.subscribe(context.getKnowledge().getId());
}
/**
* 下载文档。
*/
@GetMapping("/document/download")
public void documentDownload(
@RequestParam String shareKey,
@RequestParam BigInteger documentId,
HttpServletResponse response
) throws Exception {
KnowledgeShareAuthContext context = knowledgeShareService.assertUrlShareAccess(
shareKey,
null,
KnowledgeShareActionScope.VIEW.name()
);
Document document = documentService.getById(documentId);
if (document == null || document.getCollectionId() == null
|| document.getCollectionId().compareTo(context.getKnowledge().getId()) != 0) {
throw new BusinessException("文档不存在");
}
response.setContentType("application/octet-stream");
response.setCharacterEncoding(StandardCharsets.UTF_8.name());
String fileName = URLEncoder.encode(document.getTitle(), StandardCharsets.UTF_8).replaceAll("\\+", "%20");
response.setHeader("Content-disposition", "attachment;filename*=utf-8''" + fileName);
try (InputStream inputStream = fileStorageService.readStream(document.getDocumentPath())) {
IoUtil.copy(inputStream, response.getOutputStream());
response.flushBuffer();
}
}
/**
* 删除文档。
*/
@PostMapping("/document/removeDoc")
public Result<?> removeDocument(
@RequestParam String shareKey,
@JsonBody("id") String id
) {
KnowledgeShareAuthContext context = knowledgeShareService.assertUrlShareAccess(
shareKey,
null,
KnowledgeShareActionScope.CONTENT_DELETE.name()
);
Document document = documentService.getById(new BigInteger(id));
if (document == null || document.getCollectionId() == null
|| document.getCollectionId().compareTo(context.getKnowledge().getId()) != 0) {
throw new BusinessException("文档不存在");
}
boolean success = documentService.removeDoc(id);
audit(context, "删除分享文档", "KNOWLEDGE_SHARE_URL_WRITE", true,
auditDetail("knowledgeId", context.getKnowledge().getId(), "documentId", id));
return Result.ok(success);
}
/**
* 文档导入分析。
*/
@PostMapping("/document/import/analyze")
public Result<DocumentImportDtos.AnalyzeResponse> analyzeImport(
@RequestParam String shareKey,
@JsonBody DocumentImportDtos.AnalyzeRequest request
) {
KnowledgeShareAuthContext context = knowledgeShareService.assertUrlShareAccess(
shareKey,
request == null ? null : request.getKnowledgeId(),
KnowledgeShareActionScope.CONTENT_CREATE.name()
);
BigInteger knowledgeId = resolveKnowledgeId(context, request == null ? null : request.getKnowledgeId());
request.setKnowledgeId(knowledgeId);
audit(context, "分析分享文档导入", "KNOWLEDGE_SHARE_URL_WRITE", true, auditDetail("knowledgeId", knowledgeId));
return documentService.analyzeImport(request);
}
/**
* 文档导入预览。
*/
@PostMapping("/document/import/preview")
public Result<DocumentImportDtos.PreviewResponse> previewImport(
@RequestParam String shareKey,
@JsonBody DocumentImportDtos.PreviewRequest request
) {
KnowledgeShareAuthContext context = knowledgeShareService.assertUrlShareAccess(
shareKey,
request == null ? null : request.getKnowledgeId(),
KnowledgeShareActionScope.CONTENT_CREATE.name()
);
BigInteger knowledgeId = resolveKnowledgeId(context, request == null ? null : request.getKnowledgeId());
request.setKnowledgeId(knowledgeId);
audit(context, "预览分享文档导入", "KNOWLEDGE_SHARE_URL_WRITE", true, auditDetail("knowledgeId", knowledgeId));
return documentService.previewImport(request);
}
/**
* 提交文档导入。
*/
@PostMapping("/document/import/commit")
public Result<DocumentImportDtos.CommitResponse> commitImport(
@RequestParam String shareKey,
@JsonBody DocumentImportDtos.CommitRequest request
) {
KnowledgeShareAuthContext context = knowledgeShareService.assertUrlShareAccess(
shareKey,
request == null ? null : request.getKnowledgeId(),
KnowledgeShareActionScope.CONTENT_CREATE.name()
);
BigInteger knowledgeId = resolveKnowledgeId(context, request == null ? null : request.getKnowledgeId());
request.setKnowledgeId(knowledgeId);
audit(context, "提交分享文档导入", "KNOWLEDGE_SHARE_URL_WRITE", true, auditDetail("knowledgeId", knowledgeId));
return documentService.commitImport(request);
}
@PostMapping("/document/import/task/create")
public Result<DocumentImportDtos.TaskCreateResponse> createImportTask(
@RequestParam String shareKey,
@JsonBody DocumentImportDtos.TaskCreateRequest request
) {
KnowledgeShareAuthContext context = knowledgeShareService.assertUrlShareAccess(
shareKey,
request == null ? null : request.getKnowledgeId(),
KnowledgeShareActionScope.CONTENT_CREATE.name()
);
BigInteger knowledgeId = resolveKnowledgeId(context, request == null ? null : request.getKnowledgeId());
request.setKnowledgeId(knowledgeId);
audit(context, "创建分享文档导入任务", "KNOWLEDGE_SHARE_URL_WRITE", true, auditDetail("knowledgeId", knowledgeId));
return documentService.createImportTask(request);
}
@GetMapping("/document/import/task/detail")
public Result<DocumentImportDtos.TaskDetailResponse> getImportTaskDetail(
@RequestParam String shareKey,
@RequestParam BigInteger taskId
) {
KnowledgeShareAuthContext context = knowledgeShareService.assertUrlShareAccess(
shareKey,
null,
KnowledgeShareActionScope.VIEW.name()
);
Result<DocumentImportDtos.TaskDetailResponse> result = documentService.getImportTaskDetail(taskId);
BigInteger knowledgeId = result.getData() == null ? null : result.getData().getKnowledgeId();
if (knowledgeId == null || knowledgeId.compareTo(context.getKnowledge().getId()) != 0) {
throw new BusinessException("任务不存在");
}
return result;
}
@PostMapping("/document/import/task/preview")
public Result<DocumentImportDtos.PreviewResponse> previewImportTask(
@RequestParam String shareKey,
@JsonBody DocumentImportDtos.PreviewRequest request
) {
KnowledgeShareAuthContext context = knowledgeShareService.assertUrlShareAccess(
shareKey,
request == null ? null : request.getKnowledgeId(),
KnowledgeShareActionScope.CONTENT_CREATE.name()
);
BigInteger knowledgeId = resolveKnowledgeId(context, request == null ? null : request.getKnowledgeId());
request.setKnowledgeId(knowledgeId);
audit(context, "预览分享文档分块", "KNOWLEDGE_SHARE_URL_WRITE", true, auditDetail("knowledgeId", knowledgeId));
return documentService.previewImportTask(request);
}
@PostMapping("/document/import/task/startIndex")
public Result<DocumentImportDtos.TaskStartIndexResponse> startIndexTask(
@RequestParam String shareKey,
@JsonBody DocumentImportDtos.TaskStartIndexRequest request
) {
KnowledgeShareAuthContext context = knowledgeShareService.assertUrlShareAccess(
shareKey,
request == null ? null : request.getKnowledgeId(),
KnowledgeShareActionScope.CONTENT_CREATE.name()
);
BigInteger knowledgeId = resolveKnowledgeId(context, request == null ? null : request.getKnowledgeId());
request.setKnowledgeId(knowledgeId);
audit(context, "启动分享文档向量化", "KNOWLEDGE_SHARE_URL_WRITE", true, auditDetail("knowledgeId", knowledgeId));
return documentService.startIndexTask(request);
}
@PostMapping("/document/import/task/retryParse")
public Result<DocumentImportDtos.TaskStartIndexResponse> retryParseTask(
@RequestParam String shareKey,
@JsonBody DocumentImportDtos.TaskRetryRequest request
) {
KnowledgeShareAuthContext context = knowledgeShareService.assertUrlShareAccess(
shareKey,
request == null ? null : request.getKnowledgeId(),
KnowledgeShareActionScope.CONTENT_CREATE.name()
);
BigInteger knowledgeId = resolveKnowledgeId(context, request == null ? null : request.getKnowledgeId());
request.setKnowledgeId(knowledgeId);
audit(context, "重试分享文档解析", "KNOWLEDGE_SHARE_URL_WRITE", true, auditDetail("knowledgeId", knowledgeId));
return documentService.retryParseTask(request);
}
@PostMapping("/document/import/task/retryIndex")
public Result<DocumentImportDtos.TaskStartIndexResponse> retryIndexTask(
@RequestParam String shareKey,
@JsonBody DocumentImportDtos.TaskRetryRequest request
) {
KnowledgeShareAuthContext context = knowledgeShareService.assertUrlShareAccess(
shareKey,
request == null ? null : request.getKnowledgeId(),
KnowledgeShareActionScope.CONTENT_CREATE.name()
);
BigInteger knowledgeId = resolveKnowledgeId(context, request == null ? null : request.getKnowledgeId());
request.setKnowledgeId(knowledgeId);
audit(context, "重试分享文档向量化", "KNOWLEDGE_SHARE_URL_WRITE", true, auditDetail("knowledgeId", knowledgeId));
return documentService.retryIndexTask(request);
}
/**
* Chunk 分页。
*/
@GetMapping("/documentChunk/page")
public Result<Page<DocumentChunk>> documentChunkPage(
@RequestParam String shareKey,
@RequestParam BigInteger documentId,
@RequestParam(defaultValue = "1") long pageNumber,
@RequestParam(defaultValue = "10") long pageSize
) {
KnowledgeShareAuthContext context = knowledgeShareService.assertUrlShareAccess(
shareKey,
null,
KnowledgeShareActionScope.VIEW.name()
);
Document document = documentService.getById(documentId);
if (document == null || document.getCollectionId() == null
|| document.getCollectionId().compareTo(context.getKnowledge().getId()) != 0) {
throw new BusinessException("文档不存在");
}
QueryWrapper wrapper = QueryWrapper.create()
.eq(DocumentChunk::getDocumentId, documentId)
.orderBy("sorting asc");
return Result.ok(documentChunkService.page(new Page<>(pageNumber, pageSize), wrapper));
}
/**
* 更新 Chunk。
*/
@PostMapping("/documentChunk/update")
public Result<?> updateDocumentChunk(
@RequestParam String shareKey,
@JsonBody(required = true, skipConvertError = false)
DocumentChunkContentUpdateRequest request
) {
KnowledgeShareAuthContext context = knowledgeShareService.assertUrlShareAccess(
shareKey,
null,
KnowledgeShareActionScope.CONTENT_UPDATE.name()
);
DocumentChunk current = documentChunkService.getById(request.getId());
if (current == null || current.getDocumentCollectionId() == null
|| current.getDocumentCollectionId().compareTo(context.getKnowledge().getId()) != 0) {
throw new BusinessException("记录不存在");
}
DocumentChunk updated = documentChunkService.updateContent(
context.getKnowledge().getId(),
current.getId(),
request.getContent()
);
audit(context, "更新分享文档 Chunk", "KNOWLEDGE_SHARE_URL_WRITE", true,
auditDetail("knowledgeId", context.getKnowledge().getId(), "chunkId", request.getId()));
return Result.ok(updated);
}
/**
* 删除 Chunk。
*/
@PostMapping("/documentChunk/removeChunk")
public Result<?> removeDocumentChunk(
@RequestParam String shareKey,
@JsonBody("id") BigInteger chunkId
) {
KnowledgeShareAuthContext context = knowledgeShareService.assertUrlShareAccess(
shareKey,
null,
KnowledgeShareActionScope.CONTENT_DELETE.name()
);
DocumentChunk current = documentChunkService.getById(chunkId);
if (current == null || current.getDocumentCollectionId() == null
|| current.getDocumentCollectionId().compareTo(context.getKnowledge().getId()) != 0) {
return Result.fail(1, "记录不存在");
}
DocumentChunkDeleteResult removed = documentChunkService.deleteChunk(
context.getKnowledge().getId(),
chunkId
);
audit(context, "删除分享文档 Chunk", "KNOWLEDGE_SHARE_URL_WRITE", true,
auditDetail("knowledgeId", context.getKnowledge().getId(), "chunkId", chunkId));
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);
}
/**
* FAQ 分类列表。
*/
@GetMapping("/faqCategory/list")
public Result<List<FaqCategory>> faqCategoryList(
@RequestParam String shareKey,
@RequestParam(required = false) Boolean asTree
) {
KnowledgeShareAuthContext context = knowledgeShareService.assertUrlShareAccess(
shareKey,
null,
KnowledgeShareActionScope.VIEW.name()
);
return Result.ok(faqCategoryService.listByCollection(context.getKnowledge().getId(), asTree));
}
/**
* 保存 FAQ 分类。
*/
@PostMapping("/faqCategory/save")
public Result<?> saveFaqCategory(
@RequestParam String shareKey,
@JsonBody FaqCategory entity
) {
KnowledgeShareAuthContext context = knowledgeShareService.assertUrlShareAccess(
shareKey,
entity.getCollectionId(),
KnowledgeShareActionScope.CONTENT_CREATE.name()
);
audit(context, "新增 FAQ 分类", "KNOWLEDGE_SHARE_URL_WRITE", true, auditDetail("knowledgeId", entity.getCollectionId()));
return Result.ok(faqCategoryService.saveCategory(entity));
}
/**
* 更新 FAQ 分类。
*/
@PostMapping("/faqCategory/update")
public Result<?> updateFaqCategory(
@RequestParam String shareKey,
@JsonBody FaqCategory entity
) {
KnowledgeShareAuthContext context = knowledgeShareService.assertUrlShareAccess(
shareKey,
null,
KnowledgeShareActionScope.CONTENT_UPDATE.name()
);
audit(context, "更新 FAQ 分类", "KNOWLEDGE_SHARE_URL_WRITE", true,
auditDetail("knowledgeId", context.getKnowledge().getId(), "categoryId", entity.getId()));
return Result.ok(faqCategoryService.updateCategory(entity));
}
/**
* 删除 FAQ 分类。
*/
@PostMapping("/faqCategory/remove")
public Result<?> removeFaqCategory(
@RequestParam String shareKey,
@JsonBody("id") BigInteger id
) {
KnowledgeShareAuthContext context = knowledgeShareService.assertUrlShareAccess(
shareKey,
null,
KnowledgeShareActionScope.CONTENT_DELETE.name()
);
audit(context, "删除 FAQ 分类", "KNOWLEDGE_SHARE_URL_WRITE", true,
auditDetail("knowledgeId", context.getKnowledge().getId(), "categoryId", id));
return Result.ok(faqCategoryService.removeCategory(id));
}
/**
* FAQ 分页。
*/
@GetMapping("/faqItem/page")
public Result<Page<FaqItem>> faqPage(
@RequestParam String shareKey,
HttpServletRequest request,
@RequestParam(defaultValue = "1") long pageNumber,
@RequestParam(defaultValue = "10") long pageSize
) {
KnowledgeShareAuthContext context = knowledgeShareService.assertUrlShareAccess(
shareKey,
null,
KnowledgeShareActionScope.VIEW.name()
);
faqCategoryService.ensureDefaultCategory(context.getKnowledge().getId());
QueryWrapper queryWrapper = QueryWrapper.create()
.eq(FaqItem::getCollectionId, context.getKnowledge().getId());
String keyword = request.getParameter("keyword");
String question = request.getParameter("question");
if (StringUtils.hasText(keyword)) {
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");
if (StringUtils.hasText(categoryId)) {
List<BigInteger> descendantIds =
faqCategoryService.findDescendantIds(context.getKnowledge().getId(), new BigInteger(categoryId));
if (descendantIds.isEmpty()) {
queryWrapper.eq(FaqItem::getId, BigInteger.ZERO);
} else {
queryWrapper.in(FaqItem::getCategoryId, descendantIds);
}
}
queryWrapper.orderBy("order_no asc");
Page<FaqItem> page = faqItemService.page(new Page<>(pageNumber, pageSize), queryWrapper);
Map<BigInteger, String> pathMap = faqCategoryService.buildPathMap(context.getKnowledge().getId());
if (page.getRecords() != null) {
for (FaqItem record : page.getRecords()) {
record.setCategoryPath(pathMap.get(record.getCategoryId()));
}
}
return Result.ok(page);
}
/**
* FAQ 详情。
*/
@GetMapping("/faqItem/detail")
public Result<FaqItem> faqDetail(
@RequestParam String shareKey,
@RequestParam String id
) {
KnowledgeShareAuthContext context = knowledgeShareService.assertUrlShareAccess(
shareKey,
null,
KnowledgeShareActionScope.VIEW.name()
);
FaqItem item = faqItemService.getById(id);
if (item == null || item.getCollectionId() == null
|| item.getCollectionId().compareTo(context.getKnowledge().getId()) != 0) {
throw new BusinessException("FAQ不存在");
}
return Result.ok(item);
}
/**
* 保存 FAQ。
*/
@PostMapping("/faqItem/save")
public Result<?> saveFaq(
@RequestParam String shareKey,
@JsonBody FaqItem entity
) {
KnowledgeShareAuthContext context = knowledgeShareService.assertUrlShareAccess(
shareKey,
entity.getCollectionId(),
KnowledgeShareActionScope.CONTENT_CREATE.name()
);
audit(context, "新增 FAQ", "KNOWLEDGE_SHARE_URL_WRITE", true, auditDetail("knowledgeId", entity.getCollectionId()));
return Result.ok(faqItemService.saveFaqItem(entity));
}
/**
* 更新 FAQ。
*/
@PostMapping("/faqItem/update")
public Result<?> updateFaq(
@RequestParam String shareKey,
@JsonBody FaqItem entity
) {
KnowledgeShareAuthContext context = knowledgeShareService.assertUrlShareAccess(
shareKey,
null,
KnowledgeShareActionScope.CONTENT_UPDATE.name()
);
audit(context, "更新 FAQ", "KNOWLEDGE_SHARE_URL_WRITE", true,
auditDetail("knowledgeId", context.getKnowledge().getId(), "faqId", entity.getId()));
return Result.ok(faqItemService.updateFaqItem(entity));
}
/**
* 删除 FAQ。
*/
@PostMapping("/faqItem/remove")
public Result<?> removeFaq(
@RequestParam String shareKey,
@JsonBody("id") BigInteger id
) {
KnowledgeShareAuthContext context = knowledgeShareService.assertUrlShareAccess(
shareKey,
null,
KnowledgeShareActionScope.CONTENT_DELETE.name()
);
audit(context, "删除 FAQ", "KNOWLEDGE_SHARE_URL_WRITE", true,
auditDetail("knowledgeId", context.getKnowledge().getId(), "faqId", id));
return Result.ok(faqItemService.removeFaqItem(id));
}
/**
* 上传 FAQ 图片。
*/
@PostMapping(value = "/faqItem/uploadImage", produces = MediaType.APPLICATION_JSON_VALUE)
public Result<UploadResVo> uploadFaqImage(
@RequestParam String shareKey,
MultipartFile file,
BigInteger collectionId
) {
KnowledgeShareAuthContext context = knowledgeShareService.assertUrlShareAccess(
shareKey,
collectionId,
KnowledgeShareActionScope.CONTENT_UPDATE.name()
);
if (file == null || file.isEmpty()) {
throw new BusinessException("图片不能为空");
}
if (file.getSize() > MAX_IMAGE_SIZE_BYTES) {
throw new BusinessException("图片大小不能超过20MB");
}
if (!isAllowedImageType(file)) {
throw new BusinessException("仅支持 JPG/PNG/WEBP/GIF 图片");
}
String path = fileStorageService.save(file, "faq/" + collectionId);
UploadResVo result = new UploadResVo();
result.setPath(path);
audit(context, "上传 FAQ 图片", "KNOWLEDGE_SHARE_URL_WRITE", true, auditDetail("knowledgeId", collectionId));
return Result.ok(result);
}
/**
* 导入 FAQ Excel。
*/
@PostMapping(value = "/faqItem/importExcel", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public Result<FaqImportResultVo> importFaqExcel(
@RequestParam String shareKey,
MultipartFile file,
BigInteger collectionId
) {
KnowledgeShareAuthContext context = knowledgeShareService.assertUrlShareAccess(
shareKey,
collectionId,
KnowledgeShareActionScope.IMPORT_EXPORT.name()
);
audit(context, "导入 FAQ Excel", "KNOWLEDGE_SHARE_URL_WRITE", true, auditDetail("knowledgeId", collectionId));
return Result.ok(faqItemService.importFromExcel(collectionId, file));
}
/**
* 下载 FAQ 导入模板。
*/
@GetMapping("/faqItem/downloadImportTemplate")
public void downloadFaqImportTemplate(
@RequestParam String shareKey,
@RequestParam BigInteger collectionId,
HttpServletResponse response
) throws Exception {
KnowledgeShareAuthContext context = knowledgeShareService.assertUrlShareAccess(
shareKey,
collectionId,
KnowledgeShareActionScope.IMPORT_EXPORT.name()
);
response.setContentType("application/octet-stream");
response.setHeader(
"Content-disposition",
"attachment;filename*=utf-8''" + URLEncoder.encode("faq_import_template.xlsx", StandardCharsets.UTF_8)
);
faqItemService.writeImportTemplate(response.getOutputStream());
response.flushBuffer();
audit(context, "下载 FAQ 导入模板", "KNOWLEDGE_SHARE_URL_ACCESS", false, auditDetail("knowledgeId", collectionId));
}
/**
* 导出 FAQ Excel。
*/
@GetMapping("/faqItem/exportExcel")
public void exportFaqExcel(
@RequestParam String shareKey,
@RequestParam BigInteger collectionId,
HttpServletResponse response
) throws Exception {
KnowledgeShareAuthContext context = knowledgeShareService.assertUrlShareAccess(
shareKey,
collectionId,
KnowledgeShareActionScope.IMPORT_EXPORT.name()
);
String fileName = "faq_export_" + new SimpleDateFormat("yyyyMMddHHmmss").format(new Date()) + ".xlsx";
response.setContentType("application/octet-stream");
response.setHeader(
"Content-disposition",
"attachment;filename*=utf-8''" + URLEncoder.encode(fileName, StandardCharsets.UTF_8)
);
faqItemService.exportToExcel(collectionId, response.getOutputStream());
response.flushBuffer();
audit(context, "导出 FAQ Excel", "KNOWLEDGE_SHARE_URL_ACCESS", false, auditDetail("knowledgeId", collectionId));
}
private boolean isAllowedImageType(MultipartFile file) {
String contentType = file.getContentType();
return contentType != null && ALLOWED_IMAGE_TYPES.contains(contentType.toLowerCase());
}
/**
* 校验分享请求中的知识库标识。
*
* @param knowledgeId 知识库 ID
* @return 已校验的知识库 ID
*/
private BigInteger resolveKnowledgeId(KnowledgeShareAuthContext context, BigInteger requestedKnowledgeId) {
if (requestedKnowledgeId != null) {
return requestedKnowledgeId;
}
if (context != null && context.getKnowledge() != null && context.getKnowledge().getId() != null) {
return context.getKnowledge().getId();
}
if (context == null || context.getKnowledge() == null) {
throw new BusinessException("知识库不能为空");
}
throw new BusinessException("知识库不存在");
}
/**
* 构造允许空值的审计详情。
*
* @param keyValues 键值对
* @return 审计详情
*/
private Map<String, Object> auditDetail(Object... keyValues) {
if (keyValues == null || keyValues.length == 0) {
return new HashMap<>();
}
if ((keyValues.length & 1) != 0) {
throw new IllegalArgumentException("审计详情参数必须成对出现");
}
Map<String, Object> detail = new HashMap<>(keyValues.length / 2);
for (int index = 0; index < keyValues.length; index += 2) {
Object key = keyValues[index];
if (!(key instanceof String detailKey) || !StringUtils.hasText(detailKey)) {
throw new IllegalArgumentException("审计详情 key 必须为非空字符串");
}
detail.put(detailKey, keyValues[index + 1]);
}
return detail;
}
/**
* 记录分享访问审计。
*
* @param context 分享鉴权上下文
* @param actionName 动作名称
* @param actionType 动作类型
* @param writeOperation 是否写操作
* @param detail 审计详情
*/
private void audit(KnowledgeShareAuthContext context, String actionName, String actionType, boolean writeOperation, Map<String, Object> detail) {
Map<String, Object> payload = new HashMap<>(detail);
payload.put("shareId", context.getShare().getId());
payload.put("writeOperation", writeOperation);
knowledgeShareAuditService.log(null, actionName, actionType, "/api/v1/share/knowledge", payload);
}
private List<KnowledgeSearchResultItem> toKnowledgeSearchResult(List<com.easyagents.core.document.Document> documents) {
List<KnowledgeSearchResultItem> result = new java.util.ArrayList<>();
if (documents == null) {
return result;
}
for (int index = 0; index < documents.size(); index++) {
com.easyagents.core.document.Document document = documents.get(index);
if (document == null) {
continue;
}
KnowledgeSearchResultItem item = new KnowledgeSearchResultItem();
item.setSorting(index + 1);
item.setContent(document.getContent());
Object renderMarkdown = document.getMetadata("renderMarkdown");
item.setRenderMarkdown(renderMarkdown == null ? null : String.valueOf(renderMarkdown));
Object sourceFileName = document.getMetadata("sourceFileName");
item.setSourceFileName(sourceFileName == null ? null : String.valueOf(sourceFileName));
item.setScore(document.getScore() == null ? null : document.getScore().doubleValue());
Object hitSource = document.getMetadata("hitSource");
item.setHitSource(hitSource == null ? null : String.valueOf(hitSource));
item.setVectorScore(asDouble(document.getMetadata("vectorScore")));
item.setKeywordScore(asDouble(document.getMetadata("keywordScore")));
result.add(item);
}
return result;
}
private Double asDouble(Object value) {
if (value == null) {
return null;
}
if (value instanceof Number number) {
return number.doubleValue();
}
if (value instanceof String text && StringUtils.hasText(text)) {
try {
return Double.parseDouble(text);
} catch (NumberFormatException ignore) {
return null;
}
}
return null;
}
}

View File

@@ -1,85 +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.entity.Workflow; import tech.easyflow.admin.service.ai.WorkflowDesignerOptionService;
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;
@GetMapping("/getChainParams") /**
public Result<?> getChainParams(String currentId, String workflowId) { * 创建工作流节点兼容控制器。
if (workflowId.equals(currentId)) { *
throw new BusinessException("工作流不能作为自身子节点"); * @param workflowDesignerOptionService 工作流设计器选项服务
} */
JSONObject nodeData = new JSONObject(); public WorkFlowNodeController(WorkflowDesignerOptionService workflowDesignerOptionService) {
Workflow workflow = workflowService.getById(workflowId); this.workflowDesignerOptionService = workflowDesignerOptionService;
if (workflow == null) {
throw new BusinessException("工作流不存在: " + workflowId);
}
nodeData.put("workflowId", workflow.getId());
nodeData.put("workflowName", workflow.getTitle());
ChainDefinition definition = chainParser.parse(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; * @param currentId 当前工作流 ID
obj.put("id", IdUtil.simpleUUID()); * @param workflowId 子流程 ID
obj.put("nameDisabled", true); * @return 子流程节点配置
obj.put("dataTypeDisabled", true); * @deprecated 请使用 {@code /api/v1/workflow/designer/childWorkflow}
obj.put("deleteDisabled", true); */
obj.put("addChildDisabled", true); @Deprecated
obj.put("refType", "ref"); @GetMapping("/getChainParams")
JSONArray children = obj.getJSONArray("children"); @SaCheckPermission("/api/v1/workflow/query")
if (children != null) { public Result<JSONObject> getChainParams(BigInteger currentId, BigInteger workflowId) {
handleArray(children); return Result.ok(workflowDesignerOptionService.getChildWorkflowNodeData(currentId, workflowId));
}
}
}
} }
} }

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.ChainDefinition;
import com.easyagents.flow.core.chain.Parameter;
import com.easyagents.flow.core.chain.runtime.ChainExecutor; import com.easyagents.flow.core.chain.runtime.ChainExecutor;
import com.easyagents.flow.core.parser.ChainParser;
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.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.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;
@@ -21,8 +29,15 @@ import tech.easyflow.ai.easyagentsflow.entity.WorkflowCheckStage;
import tech.easyflow.ai.easyagentsflow.service.CodeEngineCapabilityService; import tech.easyflow.ai.easyagentsflow.service.CodeEngineCapabilityService;
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.WorkflowDatacenterContentService;
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.service.BotWorkflowService; import tech.easyflow.ai.enums.PublishStatus;
import tech.easyflow.ai.publish.WorkflowPublishAppService;
import tech.easyflow.ai.service.AiResourceApprovalStateService;
import tech.easyflow.ai.vo.OfflineImpactCheckVo;
import tech.easyflow.approval.entity.vo.ApprovalActionResult;
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;
@@ -32,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;
@@ -46,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;
@@ -64,27 +81,155 @@ 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
private ChainParser chainParser;
@Resource
private TinyFlowService tinyFlowService; private TinyFlowService tinyFlowService;
@Resource @Resource
private CodeEngineCapabilityService codeEngineCapabilityService; private CodeEngineCapabilityService codeEngineCapabilityService;
@Resource @Resource
private WorkflowCheckService workflowCheckService; private WorkflowCheckService workflowCheckService;
@Resource @Resource
private WorkflowDatacenterContentService workflowDatacenterContentService;
@Resource
private WorkflowRunningParameterResolver workflowRunningParameterResolver;
@Resource
private WorkflowResumeService workflowResumeService;
@Resource
private ResourceAccessService resourceAccessService; private ResourceAccessService resourceAccessService;
@Resource @Resource
private WorkflowVisibilityQueryHelper workflowVisibilityQueryHelper; private WorkflowVisibilityQueryHelper workflowVisibilityQueryHelper;
@Resource
private WorkflowPublishAppService workflowPublishAppService;
@Resource
private AiResourceApprovalStateService aiResourceApprovalStateService;
@Resource
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));
}
/** /**
* 节点单独运行 * 节点单独运行
*/ */
@@ -106,6 +251,16 @@ 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) {
variables = new HashMap<>();
}
variables = workflowRunningParameterResolver.normalizeRuntimeVariables(workflow.getContent(), variables);
if (StpUtil.isLogin()) {
variables.put(Constants.LOGIN_USER_KEY, SaTokenUtil.getLoginAccount());
}
Map<String, Object> res = chainExecutor.executeNode(workflowId.toString(), nodeId, variables); Map<String, Object> res = chainExecutor.executeNode(workflowId.toString(), nodeId, variables);
return Result.ok(res); return Result.ok(res);
} }
@@ -132,6 +287,8 @@ 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);
if (StpUtil.isLogin()) { if (StpUtil.isLogin()) {
variables.put(Constants.LOGIN_USER_KEY, SaTokenUtil.getLoginAccount()); variables.put(Constants.LOGIN_USER_KEY, SaTokenUtil.getLoginAccount());
} }
@@ -170,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();
} }
@@ -222,20 +379,125 @@ 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());
ChainDefinition definition = chainParser.parse(workflow.getContent()); Map<String, Object> res = workflowRunningParameterResolver.buildRunningParametersView(workflow);
if (definition == null) { if (res == null) {
return Result.fail(2, "节点配置错误,请检查! "); return Result.fail(2, "节点配置错误,请检查! ");
} }
List<Parameter> chainParameters = definition.getStartParameters();
Map<String, Object> res = new HashMap<>();
res.put("parameters", chainParameters);
res.put("title", workflow.getTitle());
res.put("description", workflow.getDescription());
res.put("icon", workflow.getIcon());
return Result.ok(res); return Result.ok(res);
} }
/**
* 提交发布审批。
*
* @param id 工作流 ID
* @param applicationReason 审批说明
* @return 审批实例 ID
*/
@PostMapping("/submitPublishApproval")
@SaCheckPermission("/api/v1/workflow/save")
@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(
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));
}
/**
* 提交下线审批。
*
* @param id 工作流 ID
* @return 审批实例 ID
*/
@PostMapping("/submitOfflineApproval")
@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) {
return buildApprovalActionResult(
workflowPublishAppService.submitOfflineApproval(id),
"已提交下线审批",
"已直接下线"
);
}
/**
* 检查工作流下线影响。
*
* @param id 工作流 ID
* @return 下线影响结果
*/
@GetMapping("/offlineImpactCheck")
@SaCheckPermission("/api/v1/workflow/save")
@RequireResourceAccess(
resource = CategoryResourceType.WORKFLOW,
action = ResourceAction.MANAGE,
lookup = ResourceLookup.WORKFLOW_ID,
idExpr = "#id",
denyMessage = "无权限管理工作流"
)
public Result<OfflineImpactCheckVo> offlineImpactCheck(@RequestParam BigInteger id) {
return Result.ok(workflowPublishAppService.checkOfflineImpact(id));
}
/**
* 提交删除审批。
*
* @param id 工作流 ID
* @return 审批实例 ID
*/
@PostMapping("/submitDeleteApproval")
@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) {
return buildApprovalActionResult(
workflowPublishAppService.submitDeleteApproval(id),
"已提交删除审批",
"已直接删除"
);
}
@GetMapping("/supportedCodeEngines") @GetMapping("/supportedCodeEngines")
@SaCheckPermission("/api/v1/workflow/query") @SaCheckPermission("/api/v1/workflow/query")
public Result<?> supportedCodeEngines() { public Result<?> supportedCodeEngines() {
@@ -274,6 +536,7 @@ public class WorkflowController extends BaseCurdController<WorkflowService, Work
) )
public Result<Workflow> detail(String id) { public Result<Workflow> detail(String id) {
Workflow workflow = service.getDetail(id); Workflow workflow = service.getDetail(id);
aiResourceApprovalStateService.fillWorkflowApprovalState(workflow);
return Result.ok(workflow); return Result.ok(workflow);
} }
@@ -294,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();
@@ -333,14 +652,36 @@ public class WorkflowController extends BaseCurdController<WorkflowService, Work
public Result<List<Workflow>> list(Workflow entity, Boolean asTree, String sortKey, String sortType) { public Result<List<Workflow>> list(Workflow entity, Boolean asTree, String sortKey, String sortType) {
QueryWrapper queryWrapper = QueryWrapper.create(entity, buildOperators(entity)); QueryWrapper queryWrapper = QueryWrapper.create(entity, buildOperators(entity));
workflowVisibilityQueryHelper.applyReadableAccess(queryWrapper); workflowVisibilityQueryHelper.applyReadableAccess(queryWrapper);
applyPublishedOnlyFilter(queryWrapper);
queryWrapper.orderBy(buildOrderBy(sortKey, sortType, getDefaultOrderBy())); queryWrapper.orderBy(buildOrderBy(sortKey, sortType, getDefaultOrderBy()));
return Result.ok(service.list(queryWrapper)); List<Workflow> workflows = service.list(queryWrapper);
aiResourceApprovalStateService.fillWorkflowApprovalState(workflows);
return Result.ok(workflows);
} }
@Override @Override
protected Page<Workflow> queryPage(Page<Workflow> page, QueryWrapper queryWrapper) { protected Page<Workflow> queryPage(Page<Workflow> page, QueryWrapper queryWrapper) {
workflowVisibilityQueryHelper.applyReadableAccess(queryWrapper); workflowVisibilityQueryHelper.applyReadableAccess(queryWrapper);
return super.queryPage(page, queryWrapper); applyPublishedOnlyFilter(queryWrapper);
Page<Workflow> result = super.queryPage(page, queryWrapper);
aiResourceApprovalStateService.fillWorkflowApprovalState(result.getRecords());
aiResourceCreatorNameSupport.fillWorkflowCreatorNames(result.getRecords());
return result;
}
@Override
@PostMapping("remove")
public Result<?> remove(@JsonBody(value = "id", required = true) Serializable id) {
return Result.fail(1, "请使用发布状态操作删除工作流");
}
private Result<BigInteger> buildApprovalActionResult(ApprovalActionResult actionResult,
String approvalMessage,
String directMessage) {
return Result.ok(
actionResult.isApprovalRequired() ? approvalMessage : directMessage,
actionResult.getInstanceId()
);
} }
@Override @Override
@@ -349,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;
} }
@@ -378,4 +720,24 @@ 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) {
ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
if (attributes == null) {
return;
}
String publishedOnly = attributes.getRequest().getParameter("publishedOnly");
if ("true".equalsIgnoreCase(publishedOnly)) {
queryWrapper.eq("publish_status", PublishStatus.PUBLISHED.getCode());
}
}
} }

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);
@@ -61,4 +73,4 @@ public class WorkflowExecResultController extends BaseCurdController<WorkflowExe
} }
return res; return res;
} }
} }

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) {
@@ -60,4 +70,4 @@ public class WorkflowExecStepController extends BaseCurdController<WorkflowExecS
} }
return Result.ok(list); return Result.ok(list);
} }
} }

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

@@ -0,0 +1,162 @@
package tech.easyflow.admin.controller.ai.support;
import org.springframework.stereotype.Component;
import com.mybatisflex.core.query.QueryWrapper;
import tech.easyflow.agent.entity.Agent;
import tech.easyflow.ai.entity.DocumentCollection;
import tech.easyflow.ai.entity.Plugin;
import tech.easyflow.ai.entity.Workflow;
import tech.easyflow.skill.entity.Skill;
import tech.easyflow.system.service.SysAccountService;
import tech.easyflow.system.entity.SysAccount;
import javax.annotation.Resource;
import java.math.BigInteger;
import java.util.Collection;
import java.util.LinkedHashSet;
import java.util.Map;
import java.util.Objects;
import java.util.function.BiConsumer;
import java.util.function.Function;
/**
* 为 AI 资源批量补充创建人展示名称。
*
* <p>该组件只做展示字段填充,不参与权限或查询逻辑。</p>
*
* @author Codex
* @since 2026-04-12
*/
@Component
public class AiResourceCreatorNameSupport {
@Resource
private SysAccountService sysAccountService;
/**
* 批量填充工作流创建人名称。
*
* @param workflows 工作流集合
*/
public void fillWorkflowCreatorNames(Collection<Workflow> workflows) {
fillCreatorNames(workflows, Workflow::getCreatedBy, Workflow::setCreatedByName);
}
/**
* 批量填充知识库创建人名称。
*
* @param collections 知识库集合
*/
public void fillDocumentCollectionCreatorNames(Collection<DocumentCollection> collections) {
fillCreatorNames(collections, DocumentCollection::getCreatedBy, DocumentCollection::setCreatedByName);
}
/**
* 批量填充插件创建人名称。
*
* @param plugins 插件集合
*/
public void fillPluginCreatorNames(Collection<Plugin> plugins) {
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 + "";
}
/**
* 通用的创建人名称填充逻辑。
*
* @param resources 资源集合
* @param createdByGetter 创建人 ID 提取函数
* @param createdByNameSetter 创建人名称回填函数
* @param <T> 资源类型
*/
<T> void fillCreatorNames(
Collection<T> resources,
Function<T, Number> createdByGetter,
BiConsumer<T, String> createdByNameSetter
) {
if (resources == null || resources.isEmpty()) {
return;
}
LinkedHashSet<BigInteger> creatorIds = resources.stream()
.map(createdByGetter)
.map(this::toBigInteger)
.filter(Objects::nonNull)
.collect(java.util.stream.Collectors.toCollection(LinkedHashSet::new));
if (creatorIds.isEmpty()) {
return;
}
Map<BigInteger, String> displayNameMap = sysAccountService.resolveDisplayNameMap(creatorIds);
for (T resource : resources) {
BigInteger creatorId = toBigInteger(createdByGetter.apply(resource));
if (creatorId == null) {
continue;
}
createdByNameSetter.accept(resource, displayNameMap.getOrDefault(creatorId, creatorId.toString()));
}
}
/**
* 统一把不同数值类型转换为 {@link BigInteger}。
*
* @param value 原始数值
* @return 归一化后的 {@link BigInteger}
*/
private BigInteger toBigInteger(Number value) {
if (value == null) {
return null;
}
if (value instanceof BigInteger) {
return (BigInteger) value;
}
return BigInteger.valueOf(value.longValue());
}
}

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

@@ -1,15 +1,23 @@
package tech.easyflow.admin.controller.auth; package tech.easyflow.admin.controller.auth;
import org.springframework.web.bind.annotation.GetMapping; import cn.dev33.satoken.annotation.SaIgnore;
import tech.easyflow.auth.entity.LoginDTO;
import tech.easyflow.auth.entity.LoginVO;
import tech.easyflow.auth.service.AuthService;
import tech.easyflow.common.domain.Result;
import tech.easyflow.common.web.jsonbody.JsonBody;
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.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.*;
import tech.easyflow.auth.service.AuthCredentialKeyService;
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.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.log.annotation.LogRecord;
import javax.annotation.Resource; import javax.annotation.Resource;
import java.util.List; import java.util.List;
@@ -20,13 +28,33 @@ 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);
} }
@PostMapping("loginByApiKey")
@SaIgnore
public Result<LoginVO> loginByApiKey(@JsonBody(value = "apiKey", required = true) String apiKey) {
return Result.ok(authService.loginByApiKey(apiKey));
}
@PostMapping("logout") @PostMapping("logout")
public Result<Void> logout() { public Result<Void> logout() {
StpUtil.logout(); StpUtil.logout();
@@ -38,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

@@ -1,15 +1,27 @@
package tech.easyflow.admin.controller.dashboard; package tech.easyflow.admin.controller.dashboard;
import cn.dev33.satoken.annotation.SaCheckPermission; import cn.dev33.satoken.annotation.SaCheckPermission;
import org.springframework.http.MediaType;
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.admin.model.dashboard.DashboardOverviewQuery; import tech.easyflow.admin.model.dashboard.DashboardOverviewQuery;
import tech.easyflow.admin.model.dashboard.DashboardOverviewVo; import tech.easyflow.admin.model.dashboard.DashboardOverviewVo;
import tech.easyflow.admin.model.dashboard.DashboardUserRankItemVo;
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;
import jakarta.servlet.http.HttpServletResponse;
import java.net.URLEncoder;
import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.List;
/** /**
* 管理端工作台统计接口。 * 管理端工作台统计接口。
*/ */
@@ -18,9 +30,29 @@ import tech.easyflow.common.satoken.util.SaTokenUtil;
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")
@@ -28,4 +60,26 @@ public class DashboardController {
public Result<DashboardOverviewVo> overview(DashboardOverviewQuery query) { public Result<DashboardOverviewVo> overview(DashboardOverviewQuery query) {
return Result.ok(dashboardService.getOverview(SaTokenUtil.getLoginAccount(), query)); return Result.ok(dashboardService.getOverview(SaTokenUtil.getLoginAccount(), query));
} }
@GetMapping("/user-ranks")
@SaCheckPermission("/api/v1/dashboard/query")
public Result<List<DashboardUserRankItemVo>> userRanks(DashboardUserRankQuery query) {
return Result.ok(dashboardService.getUserRanks(SaTokenUtil.getLoginAccount(), query));
}
@GetMapping(value = "/user-ranks/export", produces = MediaType.APPLICATION_OCTET_STREAM_VALUE)
@SaCheckPermission("/api/v1/dashboard/query")
public void exportUserRanks(DashboardUserRankQuery query, HttpServletResponse response) throws Exception {
response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
response.setCharacterEncoding("utf-8");
String timestamp = new SimpleDateFormat("yyyyMMddHHmmss").format(new Date());
String fileName = URLEncoder.encode("dashboard_user_ranks_" + timestamp, "UTF-8")
.replaceAll("\\+", "%20");
response.setHeader("Content-disposition", "attachment;filename*=utf-8''" + fileName + ".xlsx");
dashboardService.exportUserRanks(
SaTokenUtil.getLoginAccount(),
query,
response.getOutputStream()
);
}
} }

View File

@@ -0,0 +1,85 @@
package tech.easyflow.admin.controller.datacenter;
import cn.dev33.satoken.annotation.SaCheckPermission;
import com.mybatisflex.core.paginate.Page;
import com.mybatisflex.core.row.Row;
import org.springframework.web.bind.annotation.*;
import tech.easyflow.common.domain.Result;
import tech.easyflow.common.entity.LoginAccount;
import tech.easyflow.common.satoken.util.SaTokenUtil;
import tech.easyflow.datacenter.entity.DatacenterTable;
import tech.easyflow.datacenter.execution.model.DatacenterQueryRequest;
import tech.easyflow.datacenter.execution.model.DatacenterSchemaResponse;
import tech.easyflow.datacenter.execution.model.DatasetRef;
import tech.easyflow.datacenter.meta.model.DatacenterBatchRemoveRequest;
import tech.easyflow.datacenter.meta.model.DatacenterSaveDescriptionsRequest;
import tech.easyflow.datacenter.execution.service.DatacenterDatasetQueryService;
import tech.easyflow.datacenter.meta.service.DatacenterDatasetRegistryService;
import javax.annotation.Resource;
import java.math.BigInteger;
import java.util.List;
@RestController
@RequestMapping("/api/v1/datacenterDataset")
public class DatacenterDatasetController {
/** 对外 Schema 接口的默认字段页码。 */
private static final long DEFAULT_FIELD_PAGE_NUMBER = 1L;
/** 对外 Schema 接口的默认字段页大小。 */
private static final long DEFAULT_FIELD_PAGE_SIZE = 200L;
@Resource
private DatacenterDatasetQueryService queryService;
@Resource
private DatacenterDatasetRegistryService registryService;
@PostMapping("/queryPage")
@SaCheckPermission("/api/v1/datacenterSource/query")
public Result<Page<Row>> queryPage(@RequestBody DatacenterQueryRequest request) {
return Result.ok(queryService.queryPage(
request, SaTokenUtil.getLoginAccount()));
}
@GetMapping("/schema")
@SaCheckPermission("/api/v1/datacenterSource/query")
public Result<DatacenterSchemaResponse> schema(
DatasetRef datasetRef,
@RequestParam(defaultValue = "1") Long fieldPageNumber,
@RequestParam(defaultValue = "200") Long fieldPageSize) {
return Result.ok(queryService.getSchema(
datasetRef, fieldPageNumber, fieldPageSize));
}
@GetMapping("/managedTables")
@SaCheckPermission("/api/v1/datacenterSource/query")
public Result<List<DatacenterTable>> managedTables(BigInteger sourceId, BigInteger catalogId) {
return Result.ok(registryService.listManagedTables(sourceId, catalogId));
}
@PostMapping("/removeBatch")
@SaCheckPermission("/api/v1/datacenterSource/save")
public Result<Integer> removeBatch(@RequestBody DatacenterBatchRemoveRequest request) {
return Result.ok(registryService.removeTables(request == null ? List.of() : request.getTableIds()));
}
@PostMapping("/saveDescriptions")
@SaCheckPermission("/api/v1/datacenterSource/save")
public Result<DatacenterSchemaResponse> saveDescriptions(@RequestBody DatacenterSaveDescriptionsRequest request) {
LoginAccount account = SaTokenUtil.getLoginAccount();
DatacenterTable table = registryService.saveDescriptions(
request == null ? null : request.getTableId(),
request == null ? null : request.getTableDesc(),
request == null ? List.of() : request.getFields(),
account
);
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

@@ -0,0 +1,119 @@
package tech.easyflow.admin.controller.datacenter;
import cn.dev33.satoken.annotation.SaCheckPermission;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.web.bind.annotation.GetMapping;
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.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
import tech.easyflow.common.domain.Result;
import tech.easyflow.common.entity.LoginAccount;
import tech.easyflow.common.satoken.util.SaTokenUtil;
import tech.easyflow.datacenter.excel.model.DatacenterExcelDeriveRequest;
import tech.easyflow.datacenter.excel.model.DatacenterExcelExportRequest;
import tech.easyflow.datacenter.excel.model.DatacenterExcelMergeRequest;
import tech.easyflow.datacenter.excel.model.DatacenterExcelSplitRequest;
import tech.easyflow.datacenter.excel.service.DatacenterExcelImportService;
import tech.easyflow.datacenter.meta.entity.DatacenterImportJob;
import java.io.File;
import java.io.FileInputStream;
import javax.annotation.Resource;
import java.math.BigInteger;
import java.net.URLEncoder;
import java.util.List;
@RestController
@RequestMapping("/api/v1/datacenterExcel")
public class DatacenterExcelController {
@Resource
private DatacenterExcelImportService excelImportService;
/**
* 上传并导入 Excel 工作簿。
*
* @param file Excel 工作簿
* @param sourceName 数据源名称,留空时使用文件名
* @return 导入任务及新建数据源标识
* @throws Exception 文件解析或数据写入失败时抛出
*/
@PostMapping("/import")
@SaCheckPermission("/api/v1/datacenterSource/save")
public Result<DatacenterImportJob> importWorkbook(
@RequestParam("file") MultipartFile file,
@RequestParam(value = "sourceName", required = false) String sourceName) throws Exception {
LoginAccount account = SaTokenUtil.getLoginAccount();
return Result.ok(excelImportService.importWorkbook(file, sourceName, account));
}
@PostMapping("/split")
@SaCheckPermission("/api/v1/datacenterSource/save")
public Result<DatacenterImportJob> split(@RequestBody DatacenterExcelSplitRequest request) {
LoginAccount account = SaTokenUtil.getLoginAccount();
return Result.ok(excelImportService.splitWorkbook(request, account));
}
@PostMapping("/merge")
@SaCheckPermission("/api/v1/datacenterSource/save")
public Result<DatacenterImportJob> merge(@RequestBody DatacenterExcelMergeRequest request) {
LoginAccount account = SaTokenUtil.getLoginAccount();
return Result.ok(excelImportService.mergeWorkbook(request, account));
}
@PostMapping("/derive")
@SaCheckPermission("/api/v1/datacenterSource/save")
public Result<DatacenterImportJob> derive(@RequestBody DatacenterExcelDeriveRequest request) {
LoginAccount account = SaTokenUtil.getLoginAccount();
return Result.ok(excelImportService.deriveWorkbook(request, account));
}
@PostMapping("/export")
@SaCheckPermission("/api/v1/datacenterSource/query")
public Result<DatacenterImportJob> export(@RequestBody DatacenterExcelExportRequest request) throws Exception {
LoginAccount account = SaTokenUtil.getLoginAccount();
return Result.ok(excelImportService.exportWorkbook(request, account));
}
@GetMapping("/importJob/detail")
@SaCheckPermission("/api/v1/datacenterSource/query")
public Result<DatacenterImportJob> jobDetail(BigInteger jobId) {
return Result.ok(excelImportService.getImportJobDetail(jobId));
}
@GetMapping("/job/detail")
@SaCheckPermission("/api/v1/datacenterSource/query")
public Result<DatacenterImportJob> newJobDetail(BigInteger jobId) {
return Result.ok(excelImportService.getImportJobDetail(jobId));
}
@GetMapping("/job/list")
@SaCheckPermission("/api/v1/datacenterSource/query")
public Result<List<DatacenterImportJob>> jobList(BigInteger sourceId, BigInteger tableId) {
return Result.ok(excelImportService.listJobs(sourceId, tableId));
}
@GetMapping("/download")
@SaCheckPermission("/api/v1/datacenterSource/query")
public void download(BigInteger jobId, HttpServletResponse response) throws Exception {
DatacenterImportJob job = excelImportService.getImportJobDetail(jobId);
if (job.getStoragePath() == null || job.getStoragePath().isBlank()) {
throw new IllegalStateException("导出文件不存在");
}
File file = new File(job.getStoragePath());
if (!file.exists()) {
throw new IllegalStateException("导出文件不存在");
}
response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
response.setCharacterEncoding("utf-8");
String fileName = URLEncoder.encode(job.getFileName(), "UTF-8").replaceAll("\\+", "%20");
response.setHeader("Content-disposition", "attachment;filename*=utf-8''" + fileName);
try (FileInputStream inputStream = new FileInputStream(file)) {
inputStream.transferTo(response.getOutputStream());
response.flushBuffer();
}
}
}

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

@@ -0,0 +1,246 @@
package tech.easyflow.admin.controller.datacenter;
import cn.dev33.satoken.annotation.SaCheckPermission;
import com.mybatisflex.core.paginate.Page;
import org.springframework.web.bind.annotation.*;
import tech.easyflow.common.domain.Result;
import tech.easyflow.common.entity.LoginAccount;
import tech.easyflow.common.satoken.util.SaTokenUtil;
import tech.easyflow.datacenter.entity.DatacenterTable;
import tech.easyflow.datacenter.execution.model.DatacenterConnectionTestResult;
import tech.easyflow.datacenter.meta.model.DatacenterBatchRegisterRequest;
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.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.service.DatacenterSourceService;
import javax.annotation.Resource;
import java.math.BigInteger;
import java.util.List;
/**
* 数据源绑定、生命周期与元数据浏览接口。
*/
@RestController
@RequestMapping("/api/v1/datacenterSource")
public class DatacenterSourceController {
@Resource
private DatacenterSourceService sourceService;
@PostMapping("/draft")
@SaCheckPermission("/api/v1/datacenterSource/save")
public Result<DatacenterSourceView> saveDraft(@RequestBody DatacenterSourceDraftRequest request) {
LoginAccount account = SaTokenUtil.getLoginAccount();
return Result.ok(sourceService.saveDraft(request, account));
}
@PostMapping("/{sourceId}/probe")
@SaCheckPermission("/api/v1/datacenterSource/query")
public Result<DatacenterConnectionTestResult> probe(@PathVariable BigInteger sourceId) {
LoginAccount account = SaTokenUtil.getLoginAccount();
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")
@SaCheckPermission("/api/v1/datacenterSource/query")
public Result<Page<DatacenterSourceView>> page(Long pageNumber, Long pageSize) {
LoginAccount account = SaTokenUtil.getLoginAccount();
return Result.ok(sourceService.pageSources(pageNumber, pageSize, account));
}
@GetMapping("/catalogs")
@SaCheckPermission("/api/v1/datacenterSource/query")
public Result<List<DatacenterCatalogMeta>> catalogs(BigInteger sourceId) {
LoginAccount account = SaTokenUtil.getLoginAccount();
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")
@SaCheckPermission("/api/v1/datacenterSource/query")
public Result<DatacenterMetadataPage<DatacenterTable>> tables(
BigInteger sourceId,
String catalogName,
String keyword,
Long pageNumber,
Long pageSize) {
LoginAccount account = SaTokenUtil.getLoginAccount();
return Result.ok(sourceService.listTables(
sourceId, catalogName, keyword, pageNumber, pageSize, account));
}
@GetMapping("/tableDetail")
@SaCheckPermission("/api/v1/datacenterSource/query")
public Result<DatacenterTableDetailMeta> tableDetail(
BigInteger sourceId,
String catalogName,
String tableName,
@RequestParam(defaultValue = "false") boolean register,
Long fieldPageNumber,
Long fieldPageSize) {
LoginAccount account = SaTokenUtil.getLoginAccount();
return Result.ok(sourceService.getTableDetail(
sourceId, catalogName, tableName, register,
fieldPageNumber, fieldPageSize, account));
}
@PostMapping("/registerBatch")
@SaCheckPermission("/api/v1/datacenterSource/save")
public Result<List<DatacenterTable>> registerBatch(@RequestBody DatacenterBatchRegisterRequest request) {
LoginAccount account = SaTokenUtil.getLoginAccount();
return Result.ok(sourceService.batchRegisterTables(request, account));
}
@PostMapping("/remove")
@SaCheckPermission("/api/v1/datacenterSource/save")
public Result<Void> remove(@RequestBody DatacenterRemoveSourceRequest request) {
LoginAccount account = SaTokenUtil.getLoginAccount();
sourceService.removeSource(request == null ? null : request.getSourceId(), account);
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

@@ -1,195 +0,0 @@
package tech.easyflow.admin.controller.datacenter;
import cn.dev33.satoken.annotation.SaCheckPermission;
import cn.hutool.core.collection.CollectionUtil;
import cn.idev.excel.EasyExcel;
import cn.idev.excel.FastExcel;
import com.alibaba.fastjson2.JSONObject;
import com.mybatisflex.core.paginate.Page;
import com.mybatisflex.core.query.QueryWrapper;
import com.mybatisflex.core.row.Row;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import tech.easyflow.common.domain.Result;
import tech.easyflow.common.entity.DatacenterQuery;
import tech.easyflow.common.entity.LoginAccount;
import tech.easyflow.common.satoken.util.SaTokenUtil;
import tech.easyflow.common.web.controller.BaseCurdController;
import tech.easyflow.datacenter.entity.DatacenterTable;
import tech.easyflow.datacenter.entity.DatacenterTableField;
import tech.easyflow.datacenter.entity.vo.HeaderVo;
import tech.easyflow.datacenter.excel.ReadDataListener;
import tech.easyflow.datacenter.excel.ReadResVo;
import tech.easyflow.datacenter.service.DatacenterTableFieldService;
import tech.easyflow.datacenter.service.DatacenterTableService;
import javax.annotation.Resource;
import java.io.InputStream;
import java.math.BigInteger;
import java.net.URLEncoder;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;
import java.util.Map;
/**
* 数据中枢表 控制层。
*
* @author ArkLight
* @since 2025-07-10
*/
@RestController
@RequestMapping("/api/v1/datacenterTable")
public class DatacenterTableController extends BaseCurdController<DatacenterTableService, DatacenterTable> {
@Resource
private DatacenterTableFieldService fieldsService;
public DatacenterTableController(DatacenterTableService service) {
super(service);
}
@Override
protected Result onSaveOrUpdateBefore(DatacenterTable entity, boolean isSave) {
LoginAccount loginUser = SaTokenUtil.getLoginAccount();
if (isSave) {
commonFiled(entity, loginUser.getId(), loginUser.getTenantId(), loginUser.getDeptId());
} else {
entity.setModifiedBy(loginUser.getId());
}
return super.onSaveOrUpdateBefore(entity, isSave);
}
@PostMapping("/saveTable")
@SaCheckPermission("/api/v1/datacenterTable/save")
public Result<Void> saveTable(@RequestBody DatacenterTable entity) {
LoginAccount loginUser = SaTokenUtil.getLoginAccount();
List<DatacenterTableField> fields = entity.getFields();
if (CollectionUtil.isEmpty(fields)) {
return Result.fail(99, "字段不能为空");
}
BigInteger id = entity.getId();
if (id == null) {
commonFiled(entity, loginUser.getId(), loginUser.getTenantId(), loginUser.getDeptId());
} else {
entity.setModified(new Date());
entity.setModifiedBy(loginUser.getId());
}
service.saveTable(entity, loginUser);
return Result.ok();
}
@GetMapping("/detailInfo")
@SaCheckPermission("/api/v1/datacenterTable/query")
public Result<DatacenterTable> detailInfo(BigInteger tableId) {
DatacenterTable table = service.getById(tableId);
QueryWrapper wrapper = QueryWrapper.create();
wrapper.eq(DatacenterTableField::getTableId, tableId);
wrapper.orderBy("id");
List<DatacenterTableField> fields = fieldsService.list(wrapper);
table.setFields(fields);
return Result.ok(table);
}
@GetMapping("/removeTable")
@SaCheckPermission("/api/v1/datacenterTable/remove")
public Result<Void> removeTable(BigInteger tableId) {
service.removeTable(tableId);
return Result.ok();
}
@GetMapping("/getHeaders")
@SaCheckPermission("/api/v1/datacenterTable/query")
public Result<List<HeaderVo>> getHeaders(BigInteger tableId) {
List<HeaderVo> res = service.getHeaders(tableId);
return Result.ok(res);
}
@GetMapping("/getPageData")
@SaCheckPermission("/api/v1/datacenterTable/query")
public Result<Page<Row>> getPageData(DatacenterQuery where) {
Page<Row> res = service.getPageData(where);
return Result.ok(res);
}
@PostMapping("/saveValue")
@SaCheckPermission("/api/v1/datacenterTable/save")
public Result<Void> saveValue(@RequestParam Map<String, Object> map) {
JSONObject object = new JSONObject(map);
BigInteger tableId = object.getBigInteger("tableId");
LoginAccount account = SaTokenUtil.getLoginAccount();
if (tableId == null) {
return Result.fail(99, "参数错误");
}
service.saveValue(tableId, object, account);
return Result.ok();
}
@PostMapping("/removeValue")
@SaCheckPermission("/api/v1/datacenterTable/remove")
public Result<Void> removeValue(@RequestParam Map<String, Object> map) {
JSONObject object = new JSONObject(map);
BigInteger tableId = object.getBigInteger("tableId");
BigInteger id = object.getBigInteger("id");
if (tableId == null || id == null) {
return Result.fail(99, "参数错误");
}
LoginAccount account = SaTokenUtil.getLoginAccount();
service.removeValue(tableId, id, account);
return Result.ok();
}
/**
* 导入数据
*/
@PostMapping("/importData")
@SaCheckPermission("/api/v1/datacenterTable/save")
public Result<ReadResVo> importData(MultipartFile file, @RequestParam Map<String, Object> map) throws Exception {
Object tableId = map.get("tableId");
DatacenterTable record = service.getById(tableId.toString());
if (record == null) {
throw new RuntimeException("数据表不存在");
}
InputStream is = file.getInputStream();
List<DatacenterTableField> fields = service.getFields(record.getId());
LoginAccount account = SaTokenUtil.getLoginAccount();
ReadDataListener listener = new ReadDataListener(record.getId(), fields, account);
FastExcel.read(is, listener)
.sheet()
.doRead();
int totalCount = listener.getTotalCount();
int errorCount = listener.getErrorCount();
int successCount = listener.getSuccessCount();
List<JSONObject> errorRows = listener.getErrorRows();
return Result.ok(new ReadResVo(successCount, errorCount, totalCount, errorRows));
}
@GetMapping("/getTemplate")
public void getTemplate(BigInteger tableId, HttpServletResponse response) throws Exception {
List<DatacenterTableField> fields = service.getFields(tableId);
// 设置响应内容类型
response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
response.setCharacterEncoding("utf-8");
// 设置文件名
String fileName = URLEncoder.encode("导入模板", "UTF-8").replaceAll("\\+", "%20");
response.setHeader("Content-disposition", "attachment;filename*=utf-8''" + fileName + ".xlsx");
// 动态表头数据
List<List<String>> headList = new ArrayList<>();
for (DatacenterTableField field : fields) {
List<String> head = new ArrayList<>();
head.add(field.getFieldName());
headList.add(head);
}
// 写入Excel
EasyExcel.write(response.getOutputStream())
.head(headList)
.sheet("模板")
.doWrite(new ArrayList<>()); // 写入空数据,只生成模板
}
}

View File

@@ -1,41 +0,0 @@
package tech.easyflow.admin.controller.datacenter;
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.web.controller.BaseCurdController;
import tech.easyflow.datacenter.entity.DatacenterTableField;
import tech.easyflow.datacenter.service.DatacenterTableFieldService;
import java.util.Date;
/**
* 控制层。
*
* @author ArkLight
* @since 2025-07-10
*/
@RestController
@RequestMapping("/api/v1/datacenterTableFields")
@UsePermission(moduleName = "/api/v1/datacenterTable")
public class DatacenterTableFieldsController extends BaseCurdController<DatacenterTableFieldService, DatacenterTableField> {
public DatacenterTableFieldsController(DatacenterTableFieldService service) {
super(service);
}
@Override
protected Result onSaveOrUpdateBefore(DatacenterTableField entity, boolean isSave) {
LoginAccount loginUser = SaTokenUtil.getLoginAccount();
if (isSave) {
commonFiled(entity, loginUser.getId(), loginUser.getTenantId(), loginUser.getDeptId());
} else {
entity.setModified(new Date());
entity.setModifiedBy(loginUser.getId());
}
return null;
}
}

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("/trigger")
@SaCheckPermission("/api/v1/sysJob/save")
@LogRecord("立即执行定时任务")
public Result<String> trigger(BigInteger id) {
LoginAccount account = SaTokenUtil.getLoginAccount();
SysJob job = requireExistingJob(id);
validateWorkflowReference(job, account);
return Result.ok(service.triggerNow(id));
}
@GetMapping("/getNextTimes") @GetMapping("/getNextTimes")
public Result<List<String>> getNextTimes(String cronExpression) throws Exception{ @SaCheckPermission("/api/v1/sysJob/save")
CronExpression ex = new CronExpression(cronExpression); public Result<List<String>> getNextTimes(String cronExpression) {
List<String> times = new ArrayList<>(); return Result.ok(service.nextFireTimes(cronExpression, 5).stream()
Date date = new Date(); .map(Date::toInstant)
for (int i = 0; i < 5; i++) { .map(jobTimeFormatter::format)
Date next = ex.getNextValidTimeAfter(date); .toList());
times.add(DateUtil.formatDateTime(next)); }
date = next;
@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不能为空");
} }
return Result.ok(times); 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());
}
return super.onSaveOrUpdateBefore(entity, isSave);
} }
}
@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 + "范围不正确");
}
}
}

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

@@ -0,0 +1,193 @@
package tech.easyflow.admin.controller.system;
import cn.dev33.satoken.annotation.SaCheckPermission;
import com.mybatisflex.core.paginate.Page;
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.satoken.util.SaTokenUtil;
import tech.easyflow.common.web.exceptions.BusinessException;
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.ApprovalFlowDetailVo;
import tech.easyflow.approval.entity.vo.ApprovalFlowPageVo;
import tech.easyflow.approval.service.ApprovalAssigneeService;
import tech.easyflow.approval.service.ApprovalFlowService;
import tech.easyflow.system.service.CategoryPermissionService;
import javax.annotation.Resource;
import java.math.BigInteger;
import java.util.List;
/**
* 审批流程管理控制器。
*/
@RestController
@RequestMapping("/api/v1/approvalFlow")
public class ApprovalFlowController {
@Resource
private ApprovalFlowService approvalFlowService;
@Resource
private CategoryPermissionService categoryPermissionService;
@Resource
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());
}
/**
* 分页查询审批流程。
*
* @param name 流程名称
* @param resourceType 资源类型
* @param actionType 动作类型
* @param status 流程状态
* @param pageNumber 页码
* @param pageSize 每页数量
* @return 分页结果
*/
@GetMapping("/page")
@SaCheckPermission("/api/v1/approvalFlow/query")
public Result<Page<ApprovalFlowPageVo>> page(String name, String resourceType, String actionType, String status,
Long pageNumber, Long pageSize) {
return Result.ok(approvalFlowService.pageFlows(name, resourceType, actionType, status, pageNumber, pageSize));
}
/**
* 查询审批流程详情。
*
* @param id 流程ID
* @return 流程详情
*/
@GetMapping("/detail")
@SaCheckPermission("/api/v1/approvalFlow/query")
public Result<ApprovalFlowDetailVo> detail(BigInteger id) {
return Result.ok(approvalFlowService.getFlowDetail(id));
}
/**
* 查询审批角色选项。
*
* @return 角色选项
*/
@GetMapping("/assigneeRoleOptions")
@SaCheckPermission("/api/v1/approvalFlow/query")
public Result<List<ApprovalAssigneeOptionVo>> assigneeRoleOptions() {
return Result.ok(approvalAssigneeService.listRoleOptions());
}
/**
* 分页查询审批用户选项。
*
* @param keyword 关键词
* @param pageNumber 页码
* @param pageSize 每页数量
* @return 用户选项分页
*/
@GetMapping("/assigneeAccountPage")
@SaCheckPermission("/api/v1/approvalFlow/query")
public Result<Page<ApprovalAssigneeOptionVo>> assigneeAccountPage(String keyword, Long pageNumber, Long pageSize) {
return Result.ok(approvalAssigneeService.pageAccountOptions(keyword, pageNumber, pageSize));
}
/**
* 新增审批流程。
*
* @param request 流程请求体
* @return 新增流程ID
*/
@PostMapping("/save")
@SaCheckPermission("/api/v1/approvalFlow/save")
public Result<BigInteger> save(@JsonBody ApprovalFlowDetailVo request) {
assertSuperAdmin();
systemFormOptionService.validateApprovalScopes(
request == null ? null : request.getResourceType(),
request == null ? null : request.getScopes());
BigInteger operatorId = SaTokenUtil.getLoginAccount().getId();
return Result.ok(approvalFlowService.saveFlow(request, operatorId));
}
/**
* 更新审批流程。
*
* @param request 流程请求体
* @return 处理结果
*/
@PostMapping("/update")
@SaCheckPermission("/api/v1/approvalFlow/save")
public Result<Void> update(@JsonBody ApprovalFlowDetailVo request) {
assertSuperAdmin();
systemFormOptionService.validateApprovalScopes(
request == null ? null : request.getResourceType(),
request == null ? null : request.getScopes());
BigInteger operatorId = SaTokenUtil.getLoginAccount().getId();
approvalFlowService.updateFlow(request, operatorId);
return Result.ok();
}
/**
* 启用审批流程。
*
* @param id 流程ID
* @return 处理结果
*/
@PostMapping("/enable")
@SaCheckPermission("/api/v1/approvalFlow/enable")
public Result<Void> enable(@JsonBody(value = "id", required = true) BigInteger id) {
assertSuperAdmin();
approvalFlowService.enableFlow(id, SaTokenUtil.getLoginAccount().getId());
return Result.ok();
}
/**
* 停用审批流程。
*
* @param id 流程ID
* @return 处理结果
*/
@PostMapping("/disable")
@SaCheckPermission("/api/v1/approvalFlow/disable")
public Result<Void> disable(@JsonBody(value = "id", required = true) BigInteger id) {
assertSuperAdmin();
approvalFlowService.disableFlow(id, SaTokenUtil.getLoginAccount().getId());
return Result.ok();
}
/**
* 删除审批流程。
*
* @param id 流程ID
* @return 处理结果
*/
@PostMapping("/remove")
@SaCheckPermission("/api/v1/approvalFlow/remove")
public Result<Void> remove(@JsonBody(value = "id", required = true) BigInteger id) {
assertSuperAdmin();
approvalFlowService.removeFlow(id);
return Result.ok();
}
private void assertSuperAdmin() {
if (!categoryPermissionService.isCurrentSuperAdmin()) {
throw new BusinessException("仅超级管理员可管理审批流程");
}
}
}

View File

@@ -0,0 +1,139 @@
package tech.easyflow.admin.controller.system;
import cn.dev33.satoken.annotation.SaCheckPermission;
import com.mybatisflex.core.paginate.Page;
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.satoken.util.SaTokenUtil;
import tech.easyflow.common.web.jsonbody.JsonBody;
import tech.easyflow.approval.entity.vo.ApprovalActionRequest;
import tech.easyflow.approval.entity.vo.ApprovalInstanceDetailVo;
import tech.easyflow.approval.entity.vo.ApprovalInstancePageVo;
import tech.easyflow.approval.service.ApprovalInstanceService;
import tech.easyflow.approval.service.ApprovalQueryService;
import javax.annotation.Resource;
import java.math.BigInteger;
/**
* 审批实例控制器。
*/
@RestController
@RequestMapping("/api/v1/approvalInstance")
public class ApprovalInstanceController {
@Resource
private ApprovalQueryService approvalQueryService;
@Resource
private ApprovalInstanceService approvalInstanceService;
/**
* 分页查询待审批列表。
*
* @param resourceType 资源类型
* @param actionType 动作类型
* @param keyword 关键词
* @param pageNumber 页码
* @param pageSize 每页数量
* @return 分页结果
*/
@GetMapping("/pendingPage")
@SaCheckPermission("/api/v1/approvalInstance/query")
public Result<Page<ApprovalInstancePageVo>> pendingPage(String resourceType, String actionType, String keyword,
Long pageNumber, Long pageSize) {
return Result.ok(approvalQueryService.pendingPage(resourceType, actionType, keyword, pageNumber, pageSize));
}
/**
* 分页查询已审批列表。
*
* @param resourceType 资源类型
* @param actionType 动作类型
* @param keyword 关键词
* @param pageNumber 页码
* @param pageSize 每页数量
* @return 分页结果
*/
@GetMapping("/processedPage")
@SaCheckPermission("/api/v1/approvalInstance/query")
public Result<Page<ApprovalInstancePageVo>> processedPage(String resourceType, String actionType, String keyword,
Long pageNumber, Long pageSize) {
return Result.ok(approvalQueryService.processedPage(resourceType, actionType, keyword, pageNumber, pageSize));
}
/**
* 分页查询我发起的审批列表。
*
* @param resourceType 资源类型
* @param actionType 动作类型
* @param keyword 关键词
* @param pageNumber 页码
* @param pageSize 每页数量
* @return 分页结果
*/
@GetMapping("/initiatedPage")
@SaCheckPermission("/api/v1/approvalInstance/query")
public Result<Page<ApprovalInstancePageVo>> initiatedPage(String resourceType, String actionType, String keyword,
Long pageNumber, Long pageSize) {
return Result.ok(approvalQueryService.initiatedPage(resourceType, actionType, keyword, pageNumber, pageSize));
}
/**
* 查询审批实例详情。
*
* @param id 审批实例ID
* @return 详情结果
*/
@GetMapping("/detail")
@SaCheckPermission("/api/v1/approvalInstance/query")
public Result<ApprovalInstanceDetailVo> detail(BigInteger id) {
return Result.ok(approvalQueryService.detail(id));
}
/**
* 通过审批。
*
* @param request 审批动作请求
* @return 处理结果
*/
@PostMapping("/approve")
@SaCheckPermission("/api/v1/approvalInstance/approve")
public Result<Void> approve(@JsonBody ApprovalActionRequest request) {
approvalInstanceService.approve(request.getInstanceId(), request.getComment(), currentOperatorId());
return Result.ok();
}
/**
* 驳回审批。
*
* @param request 审批动作请求
* @return 处理结果
*/
@PostMapping("/reject")
@SaCheckPermission("/api/v1/approvalInstance/reject")
public Result<Void> reject(@JsonBody ApprovalActionRequest request) {
approvalInstanceService.reject(request.getInstanceId(), request.getComment(), currentOperatorId());
return Result.ok();
}
/**
* 撤回审批。
*
* @param request 审批动作请求
* @return 处理结果
*/
@PostMapping("/revoke")
@SaCheckPermission("/api/v1/approvalInstance/revoke")
public Result<Void> revoke(@JsonBody ApprovalActionRequest request) {
approvalInstanceService.revoke(request.getInstanceId(), request.getComment(), currentOperatorId());
return Result.ok();
}
private BigInteger currentOperatorId() {
return SaTokenUtil.getLoginAccount().getId();
}
}

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,16 +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.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;
@@ -27,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;
/** /**
* 控制层。 * 控制层。
@@ -37,22 +43,53 @@ 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
private KnowledgeSharePermissionService knowledgeSharePermissionService;
@Resource
private WorkflowApiPermissionService workflowApiPermissionService;
/** /**
* 添加(保存)数据 * 添加(保存)数据
* *
* @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);
@@ -77,12 +114,74 @@ 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 (!isSave && entity.getPermissionIds() != null && !entity.getPermissionIds().isEmpty()) { if (entity.getPermissionIds() != null) {
// 修改的时候绑定授权接口
sysApiKeyResourceMappingService.authInterface(entity); sysApiKeyResourceMappingService.authInterface(entity);
} }
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());
}
if (entity.getWorkflowApiEnabled() != null) {
workflowApiPermissionService.replaceWorkflowApiEnabled(entity.getId(), entity.getWorkflowApiEnabled());
}
}
@Override
@GetMapping("/detail")
public Result<SysApiKey> detail(String id) {
Result<SysApiKey> result = super.detail(id);
fillApiKeyPermissions(result.getData());
return result;
} }
@Override @Override
@@ -91,11 +190,135 @@ public class SysApiKeyController extends BaseCurdController<SysApiKeyService, Sy
Result<Page<SysApiKey>> pageResult = (Result<Page<SysApiKey>>) super.page(request, sortKey, sortType, pageNumber, pageSize); Result<Page<SysApiKey>> pageResult = (Result<Page<SysApiKey>>) super.page(request, sortKey, sortType, pageNumber, pageSize);
Page<SysApiKey> data = pageResult.getData(); Page<SysApiKey> data = pageResult.getData();
List<SysApiKey> records = data.getRecords(); List<SysApiKey> records = data.getRecords();
records.forEach(record -> { records.forEach(this::fillApiKeyPermissions);
QueryWrapper queryWrapper = QueryWrapper.create().select(SysApiKeyResourceMapping::getApiKeyResourceId).eq(SysApiKeyResourceMapping::getApiKeyId, record.getId());
List<BigInteger> resourceIds = sysApiKeyResourceMappingService.listAs(queryWrapper, BigInteger.class);
record.setPermissionIds(resourceIds);
});
return pageResult; return pageResult;
} }
}
/**
* 回填访问令牌的接口与知识库授权。
*
* @param entity 访问令牌
*/
private void fillApiKeyPermissions(SysApiKey entity) {
if (entity == null || entity.getId() == null) {
return;
}
QueryWrapper interfaceWrapper = QueryWrapper.create()
.select(SysApiKeyResourceMapping::getApiKeyResourceId)
.eq(SysApiKeyResourceMapping::getApiKeyId, entity.getId())
.isNull(SysApiKeyResourceMapping::getResourceType);
List<BigInteger> resourceIds = sysApiKeyResourceMappingService.listAs(interfaceWrapper, BigInteger.class);
entity.setPermissionIds(resourceIds);
Set<String> knowledgeScopes =
knowledgeSharePermissionService.getApiPermissionScopes(entity.getId());
boolean readEnabled =
knowledgeScopes.contains(KnowledgeApiPermissionScope.KNOWLEDGE_READ.name());
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()
.select(SysApiKeyResourceMapping::getId)
.eq(SysApiKeyResourceMapping::getApiKeyId, entity.getId())
.eq(SysApiKeyResourceMapping::getResourceType, WorkflowApiPermissionService.RESOURCE_TYPE_WORKFLOW);
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

@@ -1,12 +1,17 @@
package tech.easyflow.admin.controller.system; package tech.easyflow.admin.controller.system;
import com.mybatisflex.core.query.QueryWrapper;
import tech.easyflow.common.annotation.UsePermission; import tech.easyflow.common.annotation.UsePermission;
import tech.easyflow.common.domain.Result;
import tech.easyflow.common.web.controller.BaseCurdController; import tech.easyflow.common.web.controller.BaseCurdController;
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.system.entity.SysApiKeyResource; import tech.easyflow.system.entity.SysApiKeyResource;
import tech.easyflow.system.service.SysApiKeyResourceService; import tech.easyflow.system.service.SysApiKeyResourceService;
import java.util.List;
/** /**
* 请求接口表 控制层。 * 请求接口表 控制层。
* *
@@ -20,4 +25,36 @@ public class SysApiKeyResourceController extends BaseCurdController<SysApiKeyRes
public SysApiKeyResourceController(SysApiKeyResourceService service) { public SysApiKeyResourceController(SysApiKeyResourceService service) {
super(service); super(service);
} }
}
/**
* 获取接口授权资源关键字搜索字段。
*
* @return 请求接口和标题属性
*/
@Override
protected String[] getKeywordSearchProperties() {
return new String[]{"requestInterface", "title"};
}
/**
* 查询普通 API Key 接口授权资源。
*
* <p>工作流 Public API 使用独立的全局授权开关,不进入普通接口授权列表,避免用户误以为勾选
* 具体接口资源即可完成工作流调用授权。</p>
*
* @param entity 查询条件
* @param asTree 是否树形返回
* @param sortKey 排序字段
* @param sortType 排序方向
* @return 普通接口授权资源
*/
@Override
@GetMapping("list")
public Result<List<SysApiKeyResource>> list(SysApiKeyResource entity, Boolean asTree, String sortKey, String sortType) {
QueryWrapper queryWrapper = QueryWrapper.create(entity, buildOperators(entity));
queryWrapper.notLike(SysApiKeyResource::getRequestInterface, "/public-api/workflow/");
queryWrapper.notLike(SysApiKeyResource::getRequestInterface, "/public-api/knowledge-share/");
queryWrapper.orderBy(buildOrderBy(sortKey, sortType, getDefaultOrderBy()));
return Result.ok(service.list(queryWrapper));
}
}

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, "部分部门不存在或已删除,请刷新后重试");
return Result.fail(1, "无法删除根部门");
}
} }
QueryWrapper w = QueryWrapper.create(); if (containsRootDept(records)) {
w.in(SysAccount::getDeptId, ids); return Result.fail(1, "无法删除根部门");
long count = sysAccountService.count(w); }
QueryWrapper childQuery = QueryWrapper.create();
childQuery.in(SysDept::getParentId, ids);
childQuery.notIn(SysDept::getId, ids);
if (service.count(childQuery) > 0) {
return Result.fail(1, "所选部门包含未选中的下级部门,不能删除");
}
if (isUsedByApprovalFlow(ids)) {
return Result.fail(1, "所选部门已被审批流程使用,请先调整审批配置");
}
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,17 +67,22 @@ 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)) {
if (StringUtil.hasText(positionName)) { queryWrapper.and(buildLiteralContainsCondition(
queryWrapper.where(SYS_POSITION.POSITION_NAME.like(positionName)); keyword, SYS_POSITION.POSITION_NAME, SYS_POSITION.POSITION_CODE));
} } else {
// 岗位编码 - 精确查询 // 兼容仍按旧参数调用的客户端。
if (StringUtil.hasText(positionCode)) { if (StringUtil.hasText(positionName)) {
queryWrapper.where(SYS_POSITION.POSITION_CODE.eq(positionCode)); queryWrapper.and(buildLiteralContainsCondition(positionName, SYS_POSITION.POSITION_NAME));
}
if (StringUtil.hasText(positionCode)) {
queryWrapper.where(SYS_POSITION.POSITION_CODE.eq(positionCode));
}
} }
// 状态 - 精确查询 // 状态 - 精确查询
if (StringUtil.hasText(status)) { if (StringUtil.hasText(status)) {
@@ -129,4 +134,4 @@ public class SysPositionController extends BaseCurdController<SysPositionService
} }
return null; return null;
} }
} }

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) {
@@ -33,4 +43,4 @@ public class SysUserFeedbackController extends BaseCurdController<SysUserFeedbac
} }
return super.onSaveOrUpdateBefore(entity, isSave); return super.onSaveOrUpdateBefore(entity, 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,56 @@
package tech.easyflow.admin.dto.chatworkspace;
import java.io.Serializable;
import java.math.BigInteger;
/**
* 工作台助手展示快照。
*/
public class ChatWorkspaceAssistantView implements Serializable {
private BigInteger id;
private String alias;
private String title;
private String description;
private String icon;
public BigInteger getId() {
return id;
}
public void setId(BigInteger id) {
this.id = id;
}
public String getAlias() {
return alias;
}
public void setAlias(String alias) {
this.alias = alias;
}
public String getTitle() {
return title;
}
public void setTitle(String title) {
this.title = title;
}
public String getDescription() {
return description;
}
public void setDescription(String description) {
this.description = description;
}
public String getIcon() {
return icon;
}
public void setIcon(String icon) {
this.icon = icon;
}
}

View File

@@ -0,0 +1,73 @@
package tech.easyflow.admin.dto.chatworkspace;
import tech.easyflow.chatlog.domain.dto.ChatMessageRecord;
import java.io.Serializable;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* 管理端聊天工作台完整会话视图。
*/
public class ChatWorkspaceConversationView implements Serializable {
private long total;
private List<ChatMessageRecord> records = new ArrayList<>();
private Map<String, List<ChatMessageRecord>> variantsByRound = new LinkedHashMap<>();
/**
* 获取当前主线可见消息数量。
*
* @return 主线消息数量
*/
public long getTotal() {
return total;
}
/**
* 设置当前主线可见消息数量。
*
* @param total 主线消息数量
*/
public void setTotal(long total) {
this.total = total;
}
/**
* 获取当前主线可见消息。
*
* @return 当前主线可见消息
*/
public List<ChatMessageRecord> getRecords() {
return records;
}
/**
* 设置当前主线可见消息。
*
* @param records 当前主线可见消息
*/
public void setRecords(List<ChatMessageRecord> records) {
this.records = records == null ? new ArrayList<>() : records;
}
/**
* 获取按轮次分组的全部答案版本。
*
* @return roundId 到答案版本列表的映射
*/
public Map<String, List<ChatMessageRecord>> getVariantsByRound() {
return variantsByRound;
}
/**
* 设置按轮次分组的全部答案版本。
*
* @param variantsByRound roundId 到答案版本列表的映射
*/
public void setVariantsByRound(Map<String, List<ChatMessageRecord>> variantsByRound) {
this.variantsByRound = variantsByRound == null ? new LinkedHashMap<>() : variantsByRound;
}
}

View File

@@ -0,0 +1,56 @@
package tech.easyflow.admin.dto.chatworkspace;
import java.io.Serializable;
import java.math.BigInteger;
/**
* 工作台知识库展示对象。
*/
public class ChatWorkspaceKnowledgeView implements Serializable {
private BigInteger id;
private String alias;
private String title;
private String description;
private String icon;
public BigInteger getId() {
return id;
}
public void setId(BigInteger id) {
this.id = id;
}
public String getAlias() {
return alias;
}
public void setAlias(String alias) {
this.alias = alias;
}
public String getTitle() {
return title;
}
public void setTitle(String title) {
this.title = title;
}
public String getDescription() {
return description;
}
public void setDescription(String description) {
this.description = description;
}
public String getIcon() {
return icon;
}
public void setIcon(String icon) {
this.icon = icon;
}
}

View File

@@ -0,0 +1,10 @@
package tech.easyflow.admin.dto.chatworkspace;
/**
* 管理端聊天工作台只读原因。
*/
public enum ChatWorkspaceReadOnlyReason {
ASSISTANT_OFFLINE,
ASSISTANT_DELETED,
NO_PERMISSION
}

View File

@@ -0,0 +1,48 @@
package tech.easyflow.admin.dto.chatworkspace;
import java.io.Serializable;
import java.util.ArrayList;
import java.util.List;
/**
* 工作台会话详情。
*/
public class ChatWorkspaceSessionDetailView extends ChatWorkspaceSessionView implements Serializable {
private ChatWorkspaceAssistantView assistant;
private List<ChatWorkspaceKnowledgeView> boundKnowledges = new ArrayList<>();
private List<ChatWorkspaceKnowledgeView> extraKnowledges = new ArrayList<>();
private List<String> removedExtraKnowledgeNames = new ArrayList<>();
public ChatWorkspaceAssistantView getAssistant() {
return assistant;
}
public void setAssistant(ChatWorkspaceAssistantView assistant) {
this.assistant = assistant;
}
public List<ChatWorkspaceKnowledgeView> getBoundKnowledges() {
return boundKnowledges;
}
public void setBoundKnowledges(List<ChatWorkspaceKnowledgeView> boundKnowledges) {
this.boundKnowledges = boundKnowledges == null ? new ArrayList<>() : boundKnowledges;
}
public List<ChatWorkspaceKnowledgeView> getExtraKnowledges() {
return extraKnowledges;
}
public void setExtraKnowledges(List<ChatWorkspaceKnowledgeView> extraKnowledges) {
this.extraKnowledges = extraKnowledges == null ? new ArrayList<>() : extraKnowledges;
}
public List<String> getRemovedExtraKnowledgeNames() {
return removedExtraKnowledgeNames;
}
public void setRemovedExtraKnowledgeNames(List<String> removedExtraKnowledgeNames) {
this.removedExtraKnowledgeNames = removedExtraKnowledgeNames == null ? new ArrayList<>() : removedExtraKnowledgeNames;
}
}

View File

@@ -0,0 +1,48 @@
package tech.easyflow.admin.dto.chatworkspace;
import java.io.Serializable;
import java.util.ArrayList;
import java.util.List;
/**
* 工作台会话分页结果。
*/
public class ChatWorkspaceSessionPage implements Serializable {
private Long total;
private Long pageNumber;
private Long pageSize;
private List<ChatWorkspaceSessionView> records = new ArrayList<>();
public Long getTotal() {
return total;
}
public void setTotal(Long total) {
this.total = total;
}
public Long getPageNumber() {
return pageNumber;
}
public void setPageNumber(Long pageNumber) {
this.pageNumber = pageNumber;
}
public Long getPageSize() {
return pageSize;
}
public void setPageSize(Long pageSize) {
this.pageSize = pageSize;
}
public List<ChatWorkspaceSessionView> getRecords() {
return records;
}
public void setRecords(List<ChatWorkspaceSessionView> records) {
this.records = records == null ? new ArrayList<>() : records;
}
}

View File

@@ -0,0 +1,111 @@
package tech.easyflow.admin.dto.chatworkspace;
import java.io.Serializable;
import java.math.BigInteger;
import java.util.Date;
/**
* 工作台会话摘要。
*/
public class ChatWorkspaceSessionView implements Serializable {
private BigInteger sessionId;
private BigInteger assistantId;
private String assistantCode;
private String assistantName;
private String title;
private String lastMessagePreview;
private Integer messageCount;
private Date accessAt;
private Date lastMessageAt;
private Boolean continuable;
private ChatWorkspaceReadOnlyReason readOnlyReason;
public BigInteger getSessionId() {
return sessionId;
}
public void setSessionId(BigInteger sessionId) {
this.sessionId = sessionId;
}
public BigInteger getAssistantId() {
return assistantId;
}
public void setAssistantId(BigInteger assistantId) {
this.assistantId = assistantId;
}
public String getAssistantCode() {
return assistantCode;
}
public void setAssistantCode(String assistantCode) {
this.assistantCode = assistantCode;
}
public String getAssistantName() {
return assistantName;
}
public void setAssistantName(String assistantName) {
this.assistantName = assistantName;
}
public String getTitle() {
return title;
}
public void setTitle(String title) {
this.title = title;
}
public String getLastMessagePreview() {
return lastMessagePreview;
}
public void setLastMessagePreview(String lastMessagePreview) {
this.lastMessagePreview = lastMessagePreview;
}
public Integer getMessageCount() {
return messageCount;
}
public void setMessageCount(Integer messageCount) {
this.messageCount = messageCount;
}
public Date getAccessAt() {
return accessAt;
}
public void setAccessAt(Date accessAt) {
this.accessAt = accessAt;
}
public Date getLastMessageAt() {
return lastMessageAt;
}
public void setLastMessageAt(Date lastMessageAt) {
this.lastMessageAt = lastMessageAt;
}
public Boolean getContinuable() {
return continuable;
}
public void setContinuable(Boolean continuable) {
this.continuable = continuable;
}
public ChatWorkspaceReadOnlyReason getReadOnlyReason() {
return readOnlyReason;
}
public void setReadOnlyReason(ChatWorkspaceReadOnlyReason readOnlyReason) {
this.readOnlyReason = readOnlyReason;
}
}

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,27 @@
package tech.easyflow.admin.model.ai;
/**
* 素材预览内容返回对象。
*/
public class ResourcePreviewVo {
private String content;
private boolean truncated;
public String getContent() {
return content;
}
public void setContent(String content) {
this.content = content;
}
public boolean isTruncated() {
return truncated;
}
public void setTruncated(boolean truncated) {
this.truncated = truncated;
}
}

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

@@ -0,0 +1,37 @@
package tech.easyflow.admin.model.dashboard;
/**
* 工作台智能体趋势点位。
*/
public class DashboardAssistantTrendPointVo {
private String key;
private String label;
private Long sessionTotal;
public String getKey() {
return key;
}
public void setKey(String key) {
this.key = key;
}
public String getLabel() {
return label;
}
public void setLabel(String label) {
this.label = label;
}
public Long getSessionTotal() {
return sessionTotal;
}
public void setSessionTotal(Long sessionTotal) {
this.sessionTotal = sessionTotal;
}
}

View File

@@ -0,0 +1,50 @@
package tech.easyflow.admin.model.dashboard;
import java.math.BigInteger;
import java.util.List;
/**
* 工作台智能体趋势序列。
*/
public class DashboardAssistantTrendSeriesVo {
private BigInteger assistantId;
private String label;
private Long totalSessionCount;
private List<DashboardAssistantTrendPointVo> points;
public BigInteger getAssistantId() {
return assistantId;
}
public void setAssistantId(BigInteger assistantId) {
this.assistantId = assistantId;
}
public String getLabel() {
return label;
}
public void setLabel(String label) {
this.label = label;
}
public Long getTotalSessionCount() {
return totalSessionCount;
}
public void setTotalSessionCount(Long totalSessionCount) {
this.totalSessionCount = totalSessionCount;
}
public List<DashboardAssistantTrendPointVo> getPoints() {
return points;
}
public void setPoints(List<DashboardAssistantTrendPointVo> points) {
this.points = points;
}
}

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