fix: 收口管理端页面权限与工作流运行授权

- 页面选项接口改用所属页面权限并返回最小数据视图

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

- 补充聊天记录权限迁移和权限隔离回归测试
This commit is contained in:
2026-08-07 12:51:21 +08:00
parent 6ad004da9b
commit d244a0404d
55 changed files with 3350 additions and 387 deletions

View File

@@ -1,6 +1,7 @@
package tech.easyflow.admin.controller.agent; package tech.easyflow.admin.controller.agent;
import cn.dev33.satoken.annotation.SaCheckPermission; import cn.dev33.satoken.annotation.SaCheckPermission;
import cn.dev33.satoken.annotation.SaMode;
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;
@@ -230,6 +231,7 @@ public class AgentController extends BaseCurdController<AgentService, Agent> {
* @return SSE Emitter * @return SSE Emitter
*/ */
@PostMapping("chat") @PostMapping("chat")
@SaCheckPermission("/api/v1/agent/session/query")
public SseEmitter chat(@JsonBody AgentChatRequest request) { public SseEmitter chat(@JsonBody AgentChatRequest request) {
return agentRunService.chat(request); return agentRunService.chat(request);
} }
@@ -241,6 +243,7 @@ public class AgentController extends BaseCurdController<AgentService, Agent> {
* @return SSE Emitter * @return SSE Emitter
*/ */
@PostMapping("/chat/draft") @PostMapping("/chat/draft")
@SaCheckPermission("/api/v1/agent/save")
public SseEmitter chatDraft(@JsonBody AgentDraftChatRequest request) { public SseEmitter chatDraft(@JsonBody AgentDraftChatRequest request) {
return agentRunService.chatDraft(request); return agentRunService.chatDraft(request);
} }
@@ -255,6 +258,9 @@ public class AgentController extends BaseCurdController<AgentService, Agent> {
* @return 上传结果 * @return 上传结果
*/ */
@PostMapping(value = "/media/upload", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) @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, public Result<AgentMediaUploadView> uploadMedia(@RequestParam("file") MultipartFile file,
@RequestParam("mode") String mode, @RequestParam("mode") String mode,
@RequestParam("agentId") String agentId, @RequestParam("agentId") String agentId,
@@ -269,6 +275,9 @@ public class AgentController extends BaseCurdController<AgentService, Agent> {
* @return 操作结果 * @return 操作结果
*/ */
@PostMapping("/media/delete") @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) { public Result<Void> deleteMedia(@JsonBody(value = "uploadId", required = true) String uploadId) {
agentMediaService.deleteUpload(uploadId, SaTokenUtil.getLoginAccount()); agentMediaService.deleteUpload(uploadId, SaTokenUtil.getLoginAccount());
return Result.ok(); return Result.ok();
@@ -281,6 +290,9 @@ public class AgentController extends BaseCurdController<AgentService, Agent> {
* @return 图片响应 * @return 图片响应
*/ */
@GetMapping("/media/content") @GetMapping("/media/content")
@SaCheckPermission(value = {
"/api/v1/agent/session/query", "/api/v1/agent/save"
}, mode = SaMode.OR)
@LogReporterDisabled @LogReporterDisabled
public ResponseEntity<byte[]> mediaContent(@RequestParam("reference") String reference) { public ResponseEntity<byte[]> mediaContent(@RequestParam("reference") String reference) {
AgentMediaResource resource = agentMediaService.load(reference, SaTokenUtil.getLoginAccount()); AgentMediaResource resource = agentMediaService.load(reference, SaTokenUtil.getLoginAccount());
@@ -303,6 +315,9 @@ public class AgentController extends BaseCurdController<AgentService, Agent> {
* @return 上传与读取状态 * @return 上传与读取状态
*/ */
@PostMapping(value = "/media/document/upload", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) @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, public Result<AgentDocumentUploadView> uploadDocument(@RequestParam("file") MultipartFile file,
@RequestParam("mode") String mode, @RequestParam("mode") String mode,
@RequestParam("agentId") String agentId, @RequestParam("agentId") String agentId,
@@ -320,6 +335,9 @@ public class AgentController extends BaseCurdController<AgentService, Agent> {
* @return 最新状态 * @return 最新状态
*/ */
@GetMapping("/media/document/status") @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) { public Result<AgentDocumentUploadView> documentStatus(@RequestParam("uploadId") String uploadId) {
return Result.ok(agentDocumentService.status(uploadId, SaTokenUtil.getLoginAccount())); return Result.ok(agentDocumentService.status(uploadId, SaTokenUtil.getLoginAccount()));
} }
@@ -331,6 +349,9 @@ public class AgentController extends BaseCurdController<AgentService, Agent> {
* @return 重试后的状态 * @return 重试后的状态
*/ */
@PostMapping("/media/document/retry") @PostMapping("/media/document/retry")
@SaCheckPermission(value = {
"/api/v1/agent/session/query", "/api/v1/agent/save"
}, mode = SaMode.OR)
public Result<AgentDocumentUploadView> retryDocument( public Result<AgentDocumentUploadView> retryDocument(
@JsonBody(value = "uploadId", required = true) String uploadId) { @JsonBody(value = "uploadId", required = true) String uploadId) {
return Result.ok(agentDocumentService.retry(uploadId, SaTokenUtil.getLoginAccount())); return Result.ok(agentDocumentService.retry(uploadId, SaTokenUtil.getLoginAccount()));
@@ -343,6 +364,9 @@ public class AgentController extends BaseCurdController<AgentService, Agent> {
* @return 操作结果 * @return 操作结果
*/ */
@PostMapping("/media/document/delete") @PostMapping("/media/document/delete")
@SaCheckPermission(value = {
"/api/v1/agent/session/query", "/api/v1/agent/save"
}, mode = SaMode.OR)
public Result<Void> deleteDocument( public Result<Void> deleteDocument(
@JsonBody(value = "uploadId", required = true) String uploadId) { @JsonBody(value = "uploadId", required = true) String uploadId) {
agentDocumentService.deleteUpload(uploadId, SaTokenUtil.getLoginAccount()); agentDocumentService.deleteUpload(uploadId, SaTokenUtil.getLoginAccount());
@@ -356,6 +380,9 @@ public class AgentController extends BaseCurdController<AgentService, Agent> {
* @return 文档流 * @return 文档流
*/ */
@GetMapping("/media/document/content") @GetMapping("/media/document/content")
@SaCheckPermission(value = {
"/api/v1/agent/session/query", "/api/v1/agent/save"
}, mode = SaMode.OR)
@LogReporterDisabled @LogReporterDisabled
public ResponseEntity<StreamingResponseBody> documentContent( public ResponseEntity<StreamingResponseBody> documentContent(
@RequestParam("reference") String reference) { @RequestParam("reference") String reference) {
@@ -384,6 +411,9 @@ public class AgentController extends BaseCurdController<AgentService, Agent> {
* @return 会话信息 * @return 会话信息
*/ */
@PostMapping("/composer/session") @PostMapping("/composer/session")
@SaCheckPermission(value = {
"/api/v1/agent/session/query", "/api/v1/agent/save"
}, mode = SaMode.OR)
public Result<AgentComposerSession> allocateComposerSession( public Result<AgentComposerSession> allocateComposerSession(
@JsonBody(value = "mode", required = true) String mode) { @JsonBody(value = "mode", required = true) String mode) {
return Result.ok(agentComposerDraftService.allocateSession(mode)); return Result.ok(agentComposerDraftService.allocateSession(mode));
@@ -396,6 +426,9 @@ public class AgentController extends BaseCurdController<AgentService, Agent> {
* @return 保存后的草稿 * @return 保存后的草稿
*/ */
@PostMapping("/composer/draft/persist") @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) { public Result<AgentComposerDraft> saveComposerDraft(@JsonBody AgentComposerDraft draft) {
return Result.ok(agentComposerDraftService.save(draft, SaTokenUtil.getLoginAccount())); return Result.ok(agentComposerDraftService.save(draft, SaTokenUtil.getLoginAccount()));
} }
@@ -409,6 +442,9 @@ public class AgentController extends BaseCurdController<AgentService, Agent> {
* @return 输入草稿 * @return 输入草稿
*/ */
@GetMapping("/composer/draft") @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, public Result<AgentComposerDraft> getComposerDraft(@RequestParam("mode") String mode,
@RequestParam("agentId") String agentId, @RequestParam("agentId") String agentId,
@RequestParam(value = "sessionId", required = false) String sessionId) { @RequestParam(value = "sessionId", required = false) String sessionId) {
@@ -428,6 +464,9 @@ public class AgentController extends BaseCurdController<AgentService, Agent> {
* @return 操作结果 * @return 操作结果
*/ */
@PostMapping("/composer/draft/delete") @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, public Result<Void> deleteComposerDraft(@JsonBody(value = "mode", required = true) String mode,
@JsonBody(value = "agentId", required = true) String agentId, @JsonBody(value = "agentId", required = true) String agentId,
@JsonBody(value = "sessionId", required = true) String sessionId, @JsonBody(value = "sessionId", required = true) String sessionId,
@@ -446,6 +485,7 @@ public class AgentController extends BaseCurdController<AgentService, Agent> {
* @return 操作结果 * @return 操作结果
*/ */
@PostMapping("/chat/draft/clear") @PostMapping("/chat/draft/clear")
@SaCheckPermission("/api/v1/agent/save")
public Result<Void> clearDraftSession(@JsonBody(value = "sessionId", required = true) String sessionId) { public Result<Void> clearDraftSession(@JsonBody(value = "sessionId", required = true) String sessionId) {
agentRunService.clearDraftSession(sessionId); agentRunService.clearDraftSession(sessionId);
return Result.ok(); return Result.ok();
@@ -459,6 +499,9 @@ public class AgentController extends BaseCurdController<AgentService, Agent> {
* @return 操作结果 * @return 操作结果
*/ */
@PostMapping("/run/approve") @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, public Result<Void> approve(@JsonBody("requestId") String requestId,
@JsonBody(value = "resumeToken", required = true) String resumeToken) { @JsonBody(value = "resumeToken", required = true) String resumeToken) {
agentRunService.approve(requestId, resumeToken); agentRunService.approve(requestId, resumeToken);
@@ -474,6 +517,9 @@ public class AgentController extends BaseCurdController<AgentService, Agent> {
* @return 操作结果 * @return 操作结果
*/ */
@PostMapping("/run/reject") @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, public Result<Void> reject(@JsonBody("requestId") String requestId,
@JsonBody(value = "resumeToken", required = true) String resumeToken, @JsonBody(value = "resumeToken", required = true) String resumeToken,
@JsonBody("reason") String reason) { @JsonBody("reason") String reason) {

View File

@@ -1,11 +1,15 @@
package tech.easyflow.admin.controller.agent; package tech.easyflow.admin.controller.agent;
import cn.dev33.satoken.annotation.SaCheckPermission;
import com.mybatisflex.core.keygen.impl.SnowFlakeIDKeyGenerator; import com.mybatisflex.core.keygen.impl.SnowFlakeIDKeyGenerator;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import tech.easyflow.admin.dto.chatworkspace.ChatWorkspaceConversationView; import tech.easyflow.admin.dto.chatworkspace.ChatWorkspaceConversationView;
import tech.easyflow.admin.dto.chatworkspace.ChatWorkspaceSessionDetailView; import tech.easyflow.admin.dto.chatworkspace.ChatWorkspaceSessionDetailView;
import tech.easyflow.admin.dto.chatworkspace.ChatWorkspaceSessionPage; import tech.easyflow.admin.dto.chatworkspace.ChatWorkspaceSessionPage;
import tech.easyflow.admin.service.agent.AgentSessionService; 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.dto.ChatHistoryPage;
import tech.easyflow.chatlog.domain.query.ChatPageQuery; import tech.easyflow.chatlog.domain.query.ChatPageQuery;
import tech.easyflow.common.domain.Result; import tech.easyflow.common.domain.Result;
@@ -22,17 +26,42 @@ import java.util.List;
*/ */
@RestController @RestController
@RequestMapping("/api/v1/agent/session") @RequestMapping("/api/v1/agent/session")
@SaCheckPermission("/api/v1/agent/session/query")
public class AgentSessionController { public class AgentSessionController {
private final AgentSessionService agentSessionService; private final AgentSessionService agentSessionService;
private final AgentOptionQueryService agentOptionQueryService;
/** /**
* 创建 Agent 管理端会话控制器。 * 创建 Agent 管理端会话控制器。
* *
* @param agentSessionService Agent 会话服务 * @param agentSessionService Agent 会话服务
* @param agentOptionQueryService Agent 安全选项服务
*/ */
public AgentSessionController(AgentSessionService agentSessionService) { public AgentSessionController(AgentSessionService agentSessionService,
AgentOptionQueryService agentOptionQueryService) {
this.agentSessionService = agentSessionService; 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());
} }
/** /**

View File

@@ -1,10 +1,13 @@
package tech.easyflow.admin.controller.ai; package tech.easyflow.admin.controller.ai;
import cn.dev33.satoken.annotation.SaCheckPermission;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
import tech.easyflow.agent.service.AgentOptionQueryService;
import tech.easyflow.agent.vo.AgentOptionView;
import tech.easyflow.chatlog.domain.dto.ChatHistoryPage; import tech.easyflow.chatlog.domain.dto.ChatHistoryPage;
import tech.easyflow.chatlog.domain.dto.ChatMessageRecord; import tech.easyflow.chatlog.domain.dto.ChatMessageRecord;
import tech.easyflow.chatlog.domain.dto.ChatSessionPage; import tech.easyflow.chatlog.domain.dto.ChatSessionPage;
@@ -23,21 +26,36 @@ import java.util.List;
@RestController @RestController
@RequestMapping("/api/v1/chatHistory") @RequestMapping("/api/v1/chatHistory")
@SaCheckPermission("/api/v1/chatHistory/query")
public class ChatHistoryController { public class ChatHistoryController {
private final ChatHistoryManageService chatHistoryManageService; private final ChatHistoryManageService chatHistoryManageService;
private final CategoryPermissionService categoryPermissionService; private final CategoryPermissionService categoryPermissionService;
private final AgentOptionQueryService agentOptionQueryService;
/** /**
* 创建聊天历史控制器。 * 创建聊天历史控制器。
* *
* @param chatHistoryManageService 聊天历史管理服务 * @param chatHistoryManageService 聊天历史管理服务
* @param categoryPermissionService 账号权限服务 * @param categoryPermissionService 账号权限服务
* @param agentOptionQueryService Agent 安全选项服务
*/ */
public ChatHistoryController(ChatHistoryManageService chatHistoryManageService, public ChatHistoryController(ChatHistoryManageService chatHistoryManageService,
CategoryPermissionService categoryPermissionService) { CategoryPermissionService categoryPermissionService,
AgentOptionQueryService agentOptionQueryService) {
this.chatHistoryManageService = chatHistoryManageService; this.chatHistoryManageService = chatHistoryManageService;
this.categoryPermissionService = categoryPermissionService; this.categoryPermissionService = categoryPermissionService;
this.agentOptionQueryService = agentOptionQueryService;
}
/**
* 查询聊天记录筛选可使用的 Agent。
*
* @return Agent 安全选项
*/
@GetMapping("/agentOptions")
public Result<List<AgentOptionView>> agentOptions() {
return Result.ok(agentOptionQueryService.listAgentOptions(false));
} }
/** /**

View File

@@ -13,6 +13,10 @@ 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.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.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;
@@ -40,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);
} }
@@ -47,6 +56,21 @@ 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 @Resource

View File

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

View File

@@ -4,6 +4,7 @@ import cn.dev33.satoken.annotation.SaCheckPermission;
import cn.dev33.satoken.stp.StpUtil; import cn.dev33.satoken.stp.StpUtil;
import cn.hutool.core.io.IoUtil; import cn.hutool.core.io.IoUtil;
import cn.hutool.core.util.IdUtil; import cn.hutool.core.util.IdUtil;
import com.alibaba.fastjson2.JSONObject;
import com.mybatisflex.core.paginate.Page; import com.mybatisflex.core.paginate.Page;
import com.easyagents.flow.core.chain.runtime.ChainExecutor; import com.easyagents.flow.core.chain.runtime.ChainExecutor;
import com.mybatisflex.core.query.QueryWrapper; import com.mybatisflex.core.query.QueryWrapper;
@@ -14,6 +15,8 @@ import org.springframework.web.context.request.ServletRequestAttributes;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile; import org.springframework.web.multipart.MultipartFile;
import tech.easyflow.admin.controller.ai.support.AiResourceCreatorNameSupport; import tech.easyflow.admin.controller.ai.support.AiResourceCreatorNameSupport;
import tech.easyflow.admin.model.ai.WorkflowDesignerOptionsView;
import tech.easyflow.admin.service.ai.WorkflowDesignerOptionService;
import tech.easyflow.agent.entity.AgentToolBinding; import tech.easyflow.agent.entity.AgentToolBinding;
import tech.easyflow.agent.enums.AgentToolType; import tech.easyflow.agent.enums.AgentToolType;
import tech.easyflow.agent.service.AgentToolBindingService; import tech.easyflow.agent.service.AgentToolBindingService;
@@ -43,6 +46,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;
@@ -101,12 +105,116 @@ public class WorkflowController extends BaseCurdController<WorkflowService, Work
private AiResourceCreatorNameSupport aiResourceCreatorNameSupport; private AiResourceCreatorNameSupport aiResourceCreatorNameSupport;
@Resource @Resource
private WorkflowShareResourceAccessGrantProvider workflowShareGrantProvider; 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 模型、知识库和代码引擎选项
*/
@GetMapping("/designer/options")
@SaCheckPermission("/api/v1/workflow/query")
public Result<WorkflowDesignerOptionsView> designerOptions() {
return Result.ok(workflowDesignerOptionService.listOptions(
codeEngineCapabilityService.listSupportedCodeEngines()));
}
/**
* 分页查询工作流设计器可用插件。
*
* @param pageNumber 页码
* @param pageSize 每页数量
* @return 插件安全选项分页
*/
@GetMapping("/designer/plugins")
@SaCheckPermission("/api/v1/workflow/query")
public Result<Page<WorkflowDesignerOptionsView.PluginOption>> designerPlugins(
Long pageNumber,
Long pageSize) {
return Result.ok(workflowDesignerOptionService.pagePlugins(pageNumber, pageSize));
}
/**
* 查询工作流插件节点配置。
*
* @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));
}
/** /**
* 节点单独运行 * 节点单独运行
*/ */
@@ -128,6 +236,9 @@ public class WorkflowController extends BaseCurdController<WorkflowService, Work
if (workflow == null) { if (workflow == null) {
return Result.fail(1, "工作流不存在"); return Result.fail(1, "工作流不存在");
} }
workflowCheckService.checkOrThrow(
workflow.getContent(), WorkflowCheckStage.PRE_EXECUTE, workflow.getId());
workflowDesignerOptionService.assertContentReferences(workflow.getContent());
if (variables == null) { if (variables == null) {
variables = new HashMap<>(); variables = new HashMap<>();
} }
@@ -161,6 +272,7 @@ public class WorkflowController extends BaseCurdController<WorkflowService, Work
throw new RuntimeException("工作流不存在"); throw new RuntimeException("工作流不存在");
} }
workflowCheckService.checkOrThrow(workflow.getContent(), WorkflowCheckStage.PRE_EXECUTE, workflow.getId()); workflowCheckService.checkOrThrow(workflow.getContent(), WorkflowCheckStage.PRE_EXECUTE, workflow.getId());
workflowDesignerOptionService.assertContentReferences(workflow.getContent());
variables = workflowRunningParameterResolver.normalizeRuntimeVariables(workflow.getContent(), variables); variables = workflowRunningParameterResolver.normalizeRuntimeVariables(workflow.getContent(), variables);
if (StpUtil.isLogin()) { if (StpUtil.isLogin()) {
variables.put(Constants.LOGIN_USER_KEY, SaTokenUtil.getLoginAccount()); variables.put(Constants.LOGIN_USER_KEY, SaTokenUtil.getLoginAccount());
@@ -252,6 +364,7 @@ public class WorkflowController extends BaseCurdController<WorkflowService, Work
return Result.fail(1, "can not find the workflow by id: " + id); return Result.fail(1, "can not find the workflow by id: " + id);
} }
workflowCheckService.checkOrThrow(workflow.getContent(), WorkflowCheckStage.PRE_EXECUTE, workflow.getId()); workflowCheckService.checkOrThrow(workflow.getContent(), WorkflowCheckStage.PRE_EXECUTE, workflow.getId());
workflowDesignerOptionService.assertContentReferences(workflow.getContent());
Map<String, Object> res = workflowRunningParameterResolver.buildRunningParametersView(workflow); Map<String, Object> res = workflowRunningParameterResolver.buildRunningParametersView(workflow);
if (res == null) { if (res == null) {
return Result.fail(2, "节点配置错误,请检查! "); return Result.fail(2, "节点配置错误,请检查! ");
@@ -431,6 +544,7 @@ public class WorkflowController extends BaseCurdController<WorkflowService, Work
workflow.setAlias(IdUtil.fastSimpleUUID()); workflow.setAlias(IdUtil.fastSimpleUUID());
workflow.setRevision(0); 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();
} }
@@ -497,6 +611,7 @@ public class WorkflowController extends BaseCurdController<WorkflowService, Work
} }
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();

View File

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

View File

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

View File

@@ -3,6 +3,7 @@ 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 cn.hutool.core.date.DateUtil;
import com.easyagents.flow.core.chain.Parameter; import com.easyagents.flow.core.chain.Parameter;
import com.mybatisflex.core.query.QueryWrapper;
import org.quartz.CronExpression; import org.quartz.CronExpression;
import org.springframework.util.StringUtils; import org.springframework.util.StringUtils;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
@@ -11,6 +12,9 @@ import org.springframework.web.bind.annotation.RestController;
import tech.easyflow.ai.easyagentsflow.service.WorkflowRunningParameterResolver; import tech.easyflow.ai.easyagentsflow.service.WorkflowRunningParameterResolver;
import tech.easyflow.ai.entity.Workflow; import tech.easyflow.ai.entity.Workflow;
import tech.easyflow.ai.service.WorkflowService; import tech.easyflow.ai.service.WorkflowService;
import tech.easyflow.ai.service.WorkflowUsageAuthorizationService;
import tech.easyflow.admin.model.SysJobWorkflowOptionView;
import tech.easyflow.common.constant.enums.EnumDataStatus;
import tech.easyflow.common.constant.enums.EnumJobType; import tech.easyflow.common.constant.enums.EnumJobType;
import tech.easyflow.common.domain.Result; import tech.easyflow.common.domain.Result;
import tech.easyflow.common.entity.LoginAccount; import tech.easyflow.common.entity.LoginAccount;
@@ -33,6 +37,7 @@ 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.Map;
import java.util.Objects;
/** /**
* 系统任务表 控制层。 * 系统任务表 控制层。
@@ -47,6 +52,9 @@ public class SysJobController extends BaseCurdController<SysJobService, SysJob>
/** 工作流服务。 */ /** 工作流服务。 */
private final WorkflowService workflowService; private final WorkflowService workflowService;
/** 工作流使用权限校验服务。 */
private final WorkflowUsageAuthorizationService workflowUsageAuthorizationService;
/** 资源访问控制服务。 */ /** 资源访问控制服务。 */
private final ResourceAccessService resourceAccessService; private final ResourceAccessService resourceAccessService;
@@ -58,15 +66,18 @@ public class SysJobController extends BaseCurdController<SysJobService, SysJob>
* *
* @param service 定时任务服务 * @param service 定时任务服务
* @param workflowService 工作流服务 * @param workflowService 工作流服务
* @param workflowUsageAuthorizationService 工作流使用权限校验服务
* @param resourceAccessService 资源访问控制服务 * @param resourceAccessService 资源访问控制服务
* @param workflowRunningParameterResolver 工作流运行参数解析器 * @param workflowRunningParameterResolver 工作流运行参数解析器
*/ */
public SysJobController(SysJobService service, public SysJobController(SysJobService service,
WorkflowService workflowService, WorkflowService workflowService,
WorkflowUsageAuthorizationService workflowUsageAuthorizationService,
ResourceAccessService resourceAccessService, ResourceAccessService resourceAccessService,
WorkflowRunningParameterResolver workflowRunningParameterResolver) { WorkflowRunningParameterResolver workflowRunningParameterResolver) {
super(service); super(service);
this.workflowService = workflowService; this.workflowService = workflowService;
this.workflowUsageAuthorizationService = workflowUsageAuthorizationService;
this.resourceAccessService = resourceAccessService; this.resourceAccessService = resourceAccessService;
this.workflowRunningParameterResolver = workflowRunningParameterResolver; this.workflowRunningParameterResolver = workflowRunningParameterResolver;
} }
@@ -75,6 +86,9 @@ public class SysJobController extends BaseCurdController<SysJobService, SysJob>
@SaCheckPermission("/api/v1/sysJob/save") @SaCheckPermission("/api/v1/sysJob/save")
@LogRecord("启动定时任务") @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();
} }
@@ -88,6 +102,7 @@ public class SysJobController extends BaseCurdController<SysJobService, SysJob>
} }
@GetMapping("/getNextTimes") @GetMapping("/getNextTimes")
@SaCheckPermission("/api/v1/sysJob/save")
public Result<List<String>> getNextTimes(String cronExpression) throws Exception{ public Result<List<String>> getNextTimes(String cronExpression) throws Exception{
CronExpression ex = new CronExpression(cronExpression); CronExpression ex = new CronExpression(cronExpression);
List<String> times = new ArrayList<>(); List<String> times = new ArrayList<>();
@@ -100,16 +115,72 @@ public class SysJobController extends BaseCurdController<SysJobService, SysJob>
return Result.ok(times); return Result.ok(times);
} }
/**
* 查询当前账号可用于定时任务的工作流。
*
* @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::getStatus, EnumDataStatus.AVAILABLE.getCode())
.orderBy(Workflow::getModified, false))
.stream()
.filter(workflow -> Objects.equals(workflow.getTenantId(), account.getTenantId()))
.filter(workflow -> resourceAccessService.canAccess(
account,
CategoryResourceType.WORKFLOW,
workflow,
ResourceAction.USE))
.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) {
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(entity); validateWorkflowReference(effectiveEntity, loginUser);
return super.onSaveOrUpdateBefore(entity, isSave); return super.onSaveOrUpdateBefore(entity, isSave);
} }
@@ -117,27 +188,71 @@ public class SysJobController extends BaseCurdController<SysJobService, SysJob>
* 校验工作流类型任务引用的工作流可被当前用户运行。 * 校验工作流类型任务引用的工作流可被当前用户运行。
* *
* @param entity 待保存的定时任务 * @param entity 待保存的定时任务
* @param account 当前账号
* @throws BusinessException 工作流不存在、参数非法或无运行权限时抛出 * @throws BusinessException 工作流不存在、参数非法或无运行权限时抛出
*/ */
private void validateWorkflowReference(SysJob entity) { private void validateWorkflowReference(SysJob entity, LoginAccount account) {
if (entity == null if (entity == null
|| !Integer.valueOf(EnumJobType.TINY_FLOW.getCode()).equals(entity.getJobType())) { || !Integer.valueOf(EnumJobType.TINY_FLOW.getCode()).equals(entity.getJobType())) {
return; return;
} }
BigInteger workflowId = SysJobWorkflowReferenceSupport.requireWorkflowId(entity); BigInteger workflowId = SysJobWorkflowReferenceSupport.requireWorkflowId(entity);
Workflow workflow = workflowService.getById(workflowId); Workflow workflow = workflowUsageAuthorizationService.requireUsableWorkflow(
if (workflow == null) { workflowId,
throw new BusinessException("工作流不存在,请重新选择"); account,
} "工作流不存在、已禁用或无权运行");
resourceAccessService.assertAccess(
CategoryResourceType.WORKFLOW,
workflow,
ResourceAction.USE,
"无权限运行所选工作流"
);
validateRequiredWorkflowParams(entity, workflow); 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());
}
/**
* 合并部分更新请求与原记录,生成用于权限和参数校验的有效任务状态。
*
* @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());
return effective;
}
/** /**
* 校验定时任务已填写工作流的全部必填运行参数。 * 校验定时任务已填写工作流的全部必填运行参数。
* *

View File

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

View File

@@ -31,6 +31,8 @@ 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.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.SysRole;
import tech.easyflow.system.entity.vo.SysAccountBatchActionResultVo; import tech.easyflow.system.entity.vo.SysAccountBatchActionResultVo;
@@ -43,8 +45,8 @@ 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.net.URLEncoder;
import java.util.Collection;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Collection;
import java.util.Date; import java.util.Date;
import java.util.LinkedHashSet; import java.util.LinkedHashSet;
import java.util.List; import java.util.List;
@@ -71,6 +73,7 @@ public class SysAccountController extends BaseCurdController<SysAccountService,
private final AuthCredentialKeyService credentialKeyService; private final AuthCredentialKeyService credentialKeyService;
private final SysRoleService sysRoleService; private final SysRoleService sysRoleService;
private final SystemFormOptionService systemFormOptionService;
@Resource @Resource
private AuthService authService; private AuthService authService;
@@ -80,13 +83,16 @@ public class SysAccountController extends BaseCurdController<SysAccountService,
* @param service 用户服务 * @param service 用户服务
* @param credentialKeyService 凭证密钥服务 * @param credentialKeyService 凭证密钥服务
* @param sysRoleService 角色服务 * @param sysRoleService 角色服务
* @param systemFormOptionService 用户表单安全选项服务
*/ */
public SysAccountController(SysAccountService service, public SysAccountController(SysAccountService service,
AuthCredentialKeyService credentialKeyService, AuthCredentialKeyService credentialKeyService,
SysRoleService sysRoleService) { SysRoleService sysRoleService,
SystemFormOptionService systemFormOptionService) {
super(service); super(service);
this.credentialKeyService = credentialKeyService; this.credentialKeyService = credentialKeyService;
this.sysRoleService = sysRoleService; this.sysRoleService = sysRoleService;
this.systemFormOptionService = systemFormOptionService;
} }
/** /**
@@ -188,6 +194,7 @@ public class SysAccountController extends BaseCurdController<SysAccountService,
@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();
if (isSave) { if (isSave) {
// 查询用户名是否存在 // 查询用户名是否存在
@@ -233,6 +240,17 @@ 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());
}
/** /**
* 填充账号创建的公共字段。 * 填充账号创建的公共字段。
* *

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,24 @@ 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);
} }
/**
* 查询角色表单所需的菜单和非 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 +100,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

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

View File

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

View File

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

View File

@@ -6,6 +6,7 @@ import tech.easyflow.chatlog.domain.dto.ChatSessionPage;
import tech.easyflow.chatlog.domain.dto.ChatSessionSummary; import tech.easyflow.chatlog.domain.dto.ChatSessionSummary;
import tech.easyflow.chatlog.domain.query.ChatSessionFilterQuery; import tech.easyflow.chatlog.domain.query.ChatSessionFilterQuery;
import tech.easyflow.chatlog.service.ChatHistoryManageService; import tech.easyflow.chatlog.service.ChatHistoryManageService;
import tech.easyflow.agent.service.AgentOptionQueryService;
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.system.service.CategoryPermissionService; import tech.easyflow.system.service.CategoryPermissionService;
@@ -30,7 +31,8 @@ public class ChatHistoryControllerTest {
BigInteger accountId = BigInteger.valueOf(20); BigInteger accountId = BigInteger.valueOf(20);
ChatHistoryManageService service = mock(ChatHistoryManageService.class); ChatHistoryManageService service = mock(ChatHistoryManageService.class);
CategoryPermissionService permissionService = mock(CategoryPermissionService.class); CategoryPermissionService permissionService = mock(CategoryPermissionService.class);
ChatHistoryController controller = new ChatHistoryController(service, permissionService); ChatHistoryController controller = new ChatHistoryController(
service, permissionService, mock(AgentOptionQueryService.class));
ChatSessionFilterQuery query = new ChatSessionFilterQuery(); ChatSessionFilterQuery query = new ChatSessionFilterQuery();
LoginAccount account = loginAccount(accountId); LoginAccount account = loginAccount(accountId);
when(permissionService.isSuperAdmin(account)).thenReturn(false); when(permissionService.isSuperAdmin(account)).thenReturn(false);
@@ -54,7 +56,8 @@ public class ChatHistoryControllerTest {
BigInteger sessionId = BigInteger.valueOf(30); BigInteger sessionId = BigInteger.valueOf(30);
ChatHistoryManageService service = mock(ChatHistoryManageService.class); ChatHistoryManageService service = mock(ChatHistoryManageService.class);
CategoryPermissionService permissionService = mock(CategoryPermissionService.class); CategoryPermissionService permissionService = mock(CategoryPermissionService.class);
ChatHistoryController controller = new ChatHistoryController(service, permissionService); ChatHistoryController controller = new ChatHistoryController(
service, permissionService, mock(AgentOptionQueryService.class));
LoginAccount account = loginAccount(accountId); LoginAccount account = loginAccount(accountId);
when(permissionService.isSuperAdmin(account)).thenReturn(true); when(permissionService.isSuperAdmin(account)).thenReturn(true);
when(service.getAdminSession(accountId, true, sessionId)).thenReturn(new ChatSessionSummary()); when(service.getAdminSession(accountId, true, sessionId)).thenReturn(new ChatSessionSummary());

View File

@@ -7,6 +7,7 @@ import org.testng.annotations.Test;
import tech.easyflow.ai.easyagentsflow.service.WorkflowRunningParameterResolver; import tech.easyflow.ai.easyagentsflow.service.WorkflowRunningParameterResolver;
import tech.easyflow.ai.entity.Workflow; import tech.easyflow.ai.entity.Workflow;
import tech.easyflow.ai.service.WorkflowService; import tech.easyflow.ai.service.WorkflowService;
import tech.easyflow.ai.service.WorkflowUsageAuthorizationService;
import tech.easyflow.common.constant.enums.EnumJobType; import tech.easyflow.common.constant.enums.EnumJobType;
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;
@@ -22,6 +23,7 @@ import java.util.Map;
import static org.mockito.Mockito.mock; import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.mockStatic; import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when; import static org.mockito.Mockito.when;
/** /**
@@ -37,13 +39,19 @@ public class SysJobControllerTest {
BigInteger workflowId = BigInteger.valueOf(101); BigInteger workflowId = BigInteger.valueOf(101);
SysJobService jobService = mock(SysJobService.class); SysJobService jobService = mock(SysJobService.class);
WorkflowService workflowService = mock(WorkflowService.class); WorkflowService workflowService = mock(WorkflowService.class);
WorkflowUsageAuthorizationService workflowAuthorizationService =
mock(WorkflowUsageAuthorizationService.class);
ResourceAccessService resourceAccessService = mock(ResourceAccessService.class); ResourceAccessService resourceAccessService = mock(ResourceAccessService.class);
WorkflowRunningParameterResolver parameterResolver = WorkflowRunningParameterResolver parameterResolver =
mock(WorkflowRunningParameterResolver.class); mock(WorkflowRunningParameterResolver.class);
Workflow workflow = new Workflow(); Workflow workflow = new Workflow();
workflow.setId(workflowId); workflow.setId(workflowId);
workflow.setContent("{}"); workflow.setContent("{}");
when(workflowService.getById(workflowId)).thenReturn(workflow); when(workflowAuthorizationService.requireUsableWorkflow(
org.mockito.ArgumentMatchers.eq(workflowId),
org.mockito.ArgumentMatchers.any(LoginAccount.class),
org.mockito.ArgumentMatchers.anyString()))
.thenReturn(workflow);
Parameter requiredParameter = mock(Parameter.class); Parameter requiredParameter = mock(Parameter.class);
when(requiredParameter.isRequired()).thenReturn(true); when(requiredParameter.isRequired()).thenReturn(true);
when(requiredParameter.getName()).thenReturn("user_input"); when(requiredParameter.getName()).thenReturn("user_input");
@@ -53,6 +61,7 @@ public class SysJobControllerTest {
SysJobController controller = new SysJobController( SysJobController controller = new SysJobController(
jobService, jobService,
workflowService, workflowService,
workflowAuthorizationService,
resourceAccessService, resourceAccessService,
parameterResolver parameterResolver
); );
@@ -76,4 +85,66 @@ public class SysJobControllerTest {
Assert.assertTrue(exception.getMessage().contains("用户问题")); Assert.assertTrue(exception.getMessage().contains("用户问题"));
} }
} }
/**
* 验证部分更新省略任务类型时仍按数据库中的工作流任务类型完成引用校验。
*/
@Test
public void shouldValidateMergedWorkflowReferenceOnPartialUpdate() {
BigInteger jobId = BigInteger.valueOf(201);
BigInteger oldWorkflowId = BigInteger.valueOf(301);
BigInteger newWorkflowId = BigInteger.valueOf(302);
SysJobService jobService = mock(SysJobService.class);
WorkflowService workflowService = mock(WorkflowService.class);
WorkflowUsageAuthorizationService workflowAuthorizationService =
mock(WorkflowUsageAuthorizationService.class);
ResourceAccessService resourceAccessService = mock(ResourceAccessService.class);
WorkflowRunningParameterResolver parameterResolver =
mock(WorkflowRunningParameterResolver.class);
SysJob existing = new SysJob();
existing.setId(jobId);
existing.setJobType(EnumJobType.TINY_FLOW.getCode());
existing.setJobParams(Map.of(
JobConstant.WORKFLOW_KEY, oldWorkflowId.toString(),
JobConstant.WORKFLOW_PARAMS_KEY, Map.of()
));
when(jobService.getById(jobId)).thenReturn(existing);
when(workflowAuthorizationService.requireUsableWorkflow(
org.mockito.ArgumentMatchers.eq(newWorkflowId),
org.mockito.ArgumentMatchers.any(LoginAccount.class),
org.mockito.ArgumentMatchers.anyString()))
.thenThrow(new BusinessException("无权限运行所选工作流"));
SysJob update = new SysJob();
update.setId(jobId);
update.setJobParams(Map.of(
JobConstant.WORKFLOW_KEY, newWorkflowId.toString(),
JobConstant.WORKFLOW_PARAMS_KEY, Map.of()
));
LoginAccount account = new LoginAccount();
account.setId(BigInteger.ONE);
SysJobController controller = new SysJobController(
jobService,
workflowService,
workflowAuthorizationService,
resourceAccessService,
parameterResolver
);
try (MockedStatic<SaTokenUtil> saToken = mockStatic(SaTokenUtil.class)) {
saToken.when(SaTokenUtil::getLoginAccount).thenReturn(account);
BusinessException exception = Assert.expectThrows(
BusinessException.class,
() -> controller.onSaveOrUpdateBefore(update, false)
);
Assert.assertTrue(exception.getMessage().contains("无权限"));
verify(workflowAuthorizationService).requireUsableWorkflow(
org.mockito.ArgumentMatchers.eq(newWorkflowId),
org.mockito.ArgumentMatchers.eq(account),
org.mockito.ArgumentMatchers.anyString());
}
}
} }

View File

@@ -10,6 +10,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.admin.controller.system.vo.SysAccountProfileVo; import tech.easyflow.admin.controller.system.vo.SysAccountProfileVo;
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.SysRole;
import tech.easyflow.system.service.SysAccountService; import tech.easyflow.system.service.SysAccountService;
@@ -46,7 +47,8 @@ public class SysAccountControllerTest {
SysAccountController controller = new SysAccountController( SysAccountController controller = new SysAccountController(
mock(SysAccountService.class), mock(SysAccountService.class),
mock(AuthCredentialKeyService.class), mock(AuthCredentialKeyService.class),
mock(SysRoleService.class) mock(SysRoleService.class),
mock(SystemFormOptionService.class)
); );
HttpServletRequest request = mock(HttpServletRequest.class); HttpServletRequest request = mock(HttpServletRequest.class);
when(request.getParameter("keyword")).thenReturn(" search-user "); when(request.getParameter("keyword")).thenReturn(" search-user ");
@@ -74,7 +76,8 @@ public class SysAccountControllerTest {
SysAccountController controller = new SysAccountController( SysAccountController controller = new SysAccountController(
mock(SysAccountService.class), mock(SysAccountService.class),
mock(AuthCredentialKeyService.class), mock(AuthCredentialKeyService.class),
mock(SysRoleService.class) mock(SysRoleService.class),
mock(SystemFormOptionService.class)
); );
HttpServletRequest request = mock(HttpServletRequest.class); HttpServletRequest request = mock(HttpServletRequest.class);
when(request.getParameter("keyword")).thenReturn(" "); when(request.getParameter("keyword")).thenReturn(" ");
@@ -93,7 +96,8 @@ public class SysAccountControllerTest {
SysAccountController controller = new SysAccountController( SysAccountController controller = new SysAccountController(
mock(SysAccountService.class), mock(SysAccountService.class),
mock(AuthCredentialKeyService.class), mock(AuthCredentialKeyService.class),
mock(SysRoleService.class) mock(SysRoleService.class),
mock(SystemFormOptionService.class)
); );
HttpServletRequest request = mock(HttpServletRequest.class); HttpServletRequest request = mock(HttpServletRequest.class);
when(request.getParameter("keyword")).thenReturn("a_b%c\\d"); when(request.getParameter("keyword")).thenReturn("a_b%c\\d");
@@ -118,7 +122,8 @@ public class SysAccountControllerTest {
SysAccountController controller = new SysAccountController( SysAccountController controller = new SysAccountController(
accountService, accountService,
credentialKeyService, credentialKeyService,
roleService roleService,
mock(SystemFormOptionService.class)
); );
SysAccount entity = createAccount(selectedDeptId); SysAccount entity = createAccount(selectedDeptId);
LoginAccount loginAccount = createLoginAccount(operatorId, tenantId, operatorDeptId); LoginAccount loginAccount = createLoginAccount(operatorId, tenantId, operatorDeptId);
@@ -167,7 +172,8 @@ public class SysAccountControllerTest {
SysAccountController controller = new SysAccountController( SysAccountController controller = new SysAccountController(
accountService, accountService,
credentialKeyService, credentialKeyService,
roleService roleService,
mock(SystemFormOptionService.class)
); );
SysAccount entity = createAccount(BigInteger.valueOf(200)); SysAccount entity = createAccount(BigInteger.valueOf(200));
entity.setRoleIds(List.of()); entity.setRoleIds(List.of());
@@ -202,7 +208,8 @@ public class SysAccountControllerTest {
SysAccountController controller = new SysAccountController( SysAccountController controller = new SysAccountController(
accountService, accountService,
credentialKeyService, credentialKeyService,
roleService roleService,
mock(SystemFormOptionService.class)
); );
SysAccount account = new SysAccount(); SysAccount account = new SysAccount();
account.setId(accountId); account.setId(accountId);
@@ -233,7 +240,8 @@ public class SysAccountControllerTest {
SysAccountController controller = new SysAccountController( SysAccountController controller = new SysAccountController(
accountService, accountService,
credentialKeyService, credentialKeyService,
roleService roleService,
mock(SystemFormOptionService.class)
); );
SysAccount account = new SysAccount(); SysAccount account = new SysAccount();
account.setId(accountId); account.setId(accountId);

View File

@@ -0,0 +1,169 @@
package tech.easyflow.admin.service.ai;
import com.mybatisflex.core.query.QueryWrapper;
import com.easyagents.flow.core.parser.ChainParser;
import org.mockito.MockedStatic;
import org.testng.Assert;
import org.testng.annotations.Test;
import tech.easyflow.ai.easyagentsflow.service.WorkflowDatacenterContentService;
import tech.easyflow.ai.entity.Model;
import tech.easyflow.ai.entity.Workflow;
import tech.easyflow.ai.plugin.workflow.snapshot.WorkflowPluginSnapshotResolver;
import tech.easyflow.ai.service.DocumentCollectionService;
import tech.easyflow.ai.service.ModelService;
import tech.easyflow.ai.service.PluginItemService;
import tech.easyflow.ai.service.PluginService;
import tech.easyflow.ai.service.PluginVisibilityService;
import tech.easyflow.ai.service.WorkflowService;
import tech.easyflow.ai.service.WorkflowUsageAuthorizationService;
import tech.easyflow.common.entity.LoginAccount;
import tech.easyflow.common.satoken.util.SaTokenUtil;
import tech.easyflow.common.web.exceptions.BusinessException;
import tech.easyflow.datacenter.execution.service.DatacenterDatasetQueryService;
import tech.easyflow.datacenter.meta.entity.DatacenterSource;
import tech.easyflow.datacenter.meta.service.DatacenterDatasetRegistryService;
import tech.easyflow.datacenter.meta.service.DatacenterSourceService;
import tech.easyflow.system.service.ResourceAccessService;
import java.math.BigInteger;
import java.util.List;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.when;
/**
* {@link WorkflowDesignerOptionService} 工作流引用权限测试。
*/
public class WorkflowDesignerOptionServiceTest {
/**
* 验证客户端提交候选列表之外的模型 ID 时服务端拒绝保存。
*/
@Test
public void shouldRejectModelOutsideSelectableOptions() {
ModelService modelService = mock(ModelService.class);
DocumentCollectionService knowledgeService = mock(DocumentCollectionService.class);
when(modelService.listByIds(any())).thenReturn(List.of());
when(knowledgeService.list(any(QueryWrapper.class))).thenReturn(List.of());
WorkflowDesignerOptionService service = createService(
modelService, knowledgeService, mock(DatacenterSourceService.class));
try (MockedStatic<SaTokenUtil> saToken = mockStatic(SaTokenUtil.class)) {
saToken.when(SaTokenUtil::getLoginAccount).thenReturn(loginAccount());
BusinessException exception = Assert.expectThrows(
BusinessException.class,
() -> service.assertContentReferences(
"{\"nodes\":[{\"type\":\"llmNode\",\"data\":{\"llmId\":\"99\"}}]}")
);
Assert.assertTrue(exception.getMessage().contains("模型"));
}
}
/**
* 验证工作流数据节点不能引用其他租户的数据源。
*/
@Test
public void shouldRejectCrossTenantDataSource() {
ModelService modelService = mock(ModelService.class);
DocumentCollectionService knowledgeService = mock(DocumentCollectionService.class);
DatacenterSourceService sourceService = mock(DatacenterSourceService.class);
when(modelService.listSelectableModels(any(Model.class), eq(false), eq("id"), eq("desc")))
.thenReturn(List.of());
when(knowledgeService.list(any(QueryWrapper.class))).thenReturn(List.of());
DatacenterSource source = new DatacenterSource();
source.setId(BigInteger.valueOf(9));
source.setTenantId(BigInteger.valueOf(200));
when(sourceService.getById(BigInteger.valueOf(9))).thenReturn(source);
WorkflowDesignerOptionService service = createService(
modelService, knowledgeService, sourceService);
try (MockedStatic<SaTokenUtil> saToken = mockStatic(SaTokenUtil.class)) {
saToken.when(SaTokenUtil::getLoginAccount).thenReturn(loginAccount());
BusinessException exception = Assert.expectThrows(
BusinessException.class,
() -> service.assertContentReferences("""
{"nodes":[{"type":"search-dataset-node","data":{
"datasetRef":{"sourceId":"9"}
}}]}
""")
);
Assert.assertTrue(exception.getMessage().contains("数据源"));
}
}
/**
* 验证子流程节点配置拒绝读取其他租户的工作流。
*/
@Test
public void shouldRejectCrossTenantChildWorkflow() {
WorkflowService workflowService = mock(WorkflowService.class);
Workflow workflow = new Workflow();
workflow.setId(BigInteger.valueOf(19));
workflow.setTenantId(BigInteger.valueOf(200));
when(workflowService.getById(BigInteger.valueOf(19))).thenReturn(workflow);
WorkflowDesignerOptionService service = createService(
mock(ModelService.class),
mock(DocumentCollectionService.class),
mock(DatacenterSourceService.class),
workflowService
);
try (MockedStatic<SaTokenUtil> saToken = mockStatic(SaTokenUtil.class)) {
saToken.when(SaTokenUtil::getLoginAccount).thenReturn(loginAccount());
BusinessException exception = Assert.expectThrows(
BusinessException.class,
() -> service.getChildWorkflowNodeData(
BigInteger.valueOf(10),
BigInteger.valueOf(19))
);
Assert.assertTrue(exception.getMessage().contains("子流程"));
}
}
private WorkflowDesignerOptionService createService(
ModelService modelService,
DocumentCollectionService knowledgeService,
DatacenterSourceService sourceService) {
return createService(modelService, knowledgeService, sourceService, mock(WorkflowService.class));
}
private WorkflowDesignerOptionService createService(
ModelService modelService,
DocumentCollectionService knowledgeService,
DatacenterSourceService sourceService,
WorkflowService workflowService) {
ResourceAccessService resourceAccessService = mock(ResourceAccessService.class);
return new WorkflowDesignerOptionService(
modelService,
knowledgeService,
mock(PluginService.class),
mock(PluginItemService.class),
mock(PluginVisibilityService.class),
workflowService,
new WorkflowUsageAuthorizationService(workflowService, resourceAccessService),
mock(WorkflowPluginSnapshotResolver.class),
mock(ChainParser.class),
mock(WorkflowDatacenterContentService.class),
resourceAccessService,
sourceService,
mock(DatacenterDatasetRegistryService.class),
mock(DatacenterDatasetQueryService.class)
);
}
private LoginAccount loginAccount() {
LoginAccount account = new LoginAccount();
account.setId(BigInteger.ONE);
account.setTenantId(BigInteger.valueOf(100));
return account;
}
}

View File

@@ -3,36 +3,31 @@ package tech.easyflow.ai.config;
import org.springframework.boot.context.event.ApplicationReadyEvent; import org.springframework.boot.context.event.ApplicationReadyEvent;
import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Configuration;
import org.springframework.context.event.EventListener; import org.springframework.context.event.EventListener;
import tech.easyflow.ai.mapper.*; import tech.easyflow.ai.mapper.BotCategoryMapper;
import tech.easyflow.common.util.SpringContextUtil; import tech.easyflow.common.util.SpringContextUtil;
import tech.easyflow.common.dict.DictManager; import tech.easyflow.common.dict.DictManager;
import tech.easyflow.common.dict.loader.DbDataLoader; import tech.easyflow.common.dict.loader.DbDataLoader;
import javax.annotation.Resource; import javax.annotation.Resource;
/**
* 注册仍由 Bot 兼容页面使用的数据库字典。
*/
@Configuration @Configuration
public class AiDictAutoConfig { public class AiDictAutoConfig {
@Resource /** Bot 分类字典数据访问器。 */
private WorkflowMapper workflowMapper;
@Resource
private WorkflowCategoryMapper workflowCategoryMapper;
@Resource @Resource
private BotCategoryMapper botCategoryMapper; private BotCategoryMapper botCategoryMapper;
@Resource
private ResourceCategoryMapper resourceCategoryMapper;
@Resource
private DocumentCollectionCategoryMapper documentCollectionCategoryMapper;
/**
* 应用启动完成后注册 Bot 兼容字典。
*/
@EventListener(ApplicationReadyEvent.class) @EventListener(ApplicationReadyEvent.class)
public void onApplicationStartup() { public void onApplicationStartup() {
DictManager dictManager = SpringContextUtil.getBean(DictManager.class); DictManager dictManager = SpringContextUtil.getBean(DictManager.class);
dictManager.putLoader(new DbDataLoader<>("aiWorkFlow", workflowMapper, "id", "title", null, null, false));
dictManager.putLoader(new DbDataLoader<>("aiWorkFlowCategory", workflowCategoryMapper, "id", "category_name", null, null, false));
dictManager.putLoader(new DbDataLoader<>("aiBotCategory", botCategoryMapper, "id", "category_name", null, null, false)); dictManager.putLoader(new DbDataLoader<>("aiBotCategory", botCategoryMapper, "id", "category_name", null, null, false));
dictManager.putLoader(new DbDataLoader<>("aiResourceCategory", resourceCategoryMapper, "id", "category_name", null, null, false));
dictManager.putLoader(new DbDataLoader<>("aiDocumentCollectionCategory", documentCollectionCategoryMapper, "id", "category_name", null, null, false));
} }
} }

View File

@@ -0,0 +1,76 @@
package tech.easyflow.ai.service;
import org.springframework.stereotype.Service;
import tech.easyflow.ai.entity.Workflow;
import tech.easyflow.common.constant.enums.EnumDataStatus;
import tech.easyflow.common.entity.LoginAccount;
import tech.easyflow.common.web.exceptions.BusinessException;
import tech.easyflow.system.enums.CategoryResourceType;
import tech.easyflow.system.enums.ResourceAction;
import tech.easyflow.system.service.ResourceAccessService;
import java.math.BigInteger;
import java.util.Objects;
/**
* 工作流使用权限校验服务。
*
* <p>统一封装工作流存在性、租户、启用状态和资源使用权限校验,供页面能力和后台任务复用。</p>
*/
@Service
public class WorkflowUsageAuthorizationService {
/** 工作流服务。 */
private final WorkflowService workflowService;
/** 资源访问控制服务。 */
private final ResourceAccessService resourceAccessService;
/**
* 创建工作流使用权限校验服务。
*
* @param workflowService 工作流服务
* @param resourceAccessService 资源访问控制服务
*/
public WorkflowUsageAuthorizationService(
WorkflowService workflowService,
ResourceAccessService resourceAccessService) {
this.workflowService = workflowService;
this.resourceAccessService = resourceAccessService;
}
/**
* 获取当前账号可使用的启用工作流。
*
* @param workflowId 工作流 ID
* @param account 使用工作流的账号
* @param denyMessage 校验失败提示
* @return 可使用的工作流
* @throws BusinessException 工作流不存在、未启用、跨租户或无使用权限时抛出
*/
public Workflow requireUsableWorkflow(
BigInteger workflowId,
LoginAccount account,
String denyMessage) {
String message = denyMessage == null || denyMessage.isBlank()
? "工作流不存在、已禁用或无权使用"
: denyMessage;
if (workflowId == null || account == null || account.getId() == null
|| account.getTenantId() == null) {
throw new BusinessException(403, 403, message);
}
Workflow workflow = workflowService.getById(workflowId);
boolean usable = workflow != null
&& Objects.equals(workflow.getTenantId(), account.getTenantId())
&& EnumDataStatus.AVAILABLE.getCode().equals(workflow.getStatus())
&& resourceAccessService.canAccess(
account,
CategoryResourceType.WORKFLOW,
workflow,
ResourceAction.USE);
if (!usable) {
throw new BusinessException(403, 403, message);
}
return workflow;
}
}

View File

@@ -30,6 +30,14 @@ public final class WorkflowSharePolicy {
private static final Set<String> ALLOWED_REQUESTS = Set.of( private static final Set<String> ALLOWED_REQUESTS = Set.of(
permissionKey("GET", "/api/v1/workflow/detail", ResourceAction.READ), permissionKey("GET", "/api/v1/workflow/detail", ResourceAction.READ),
permissionKey("GET", "/api/v1/workflow/getRunningParameters", ResourceAction.READ), permissionKey("GET", "/api/v1/workflow/getRunningParameters", ResourceAction.READ),
permissionKey("GET", "/api/v1/workflow/designer/options", ResourceAction.READ),
permissionKey("GET", "/api/v1/workflow/designer/plugins", ResourceAction.READ),
permissionKey("GET", "/api/v1/workflow/designer/pluginTinyFlow", ResourceAction.READ),
permissionKey("GET", "/api/v1/workflow/designer/childWorkflow", ResourceAction.READ),
permissionKey("GET", "/api/v1/workflow/designer/dataSources", ResourceAction.READ),
permissionKey("GET", "/api/v1/workflow/designer/catalogs", ResourceAction.READ),
permissionKey("GET", "/api/v1/workflow/designer/managedTables", ResourceAction.READ),
permissionKey("GET", "/api/v1/workflow/designer/schema", ResourceAction.READ),
permissionKey("POST", "/api/v1/workflow/update", ResourceAction.MANAGE), permissionKey("POST", "/api/v1/workflow/update", ResourceAction.MANAGE),
permissionKey("POST", "/api/v1/workflow/check", ResourceAction.MANAGE), permissionKey("POST", "/api/v1/workflow/check", ResourceAction.MANAGE),
permissionKey("POST", "/api/v1/workflow/singleRun", ResourceAction.USE), permissionKey("POST", "/api/v1/workflow/singleRun", ResourceAction.USE),

View File

@@ -0,0 +1,129 @@
package tech.easyflow.ai.service;
import org.junit.Assert;
import org.junit.Test;
import tech.easyflow.ai.entity.Workflow;
import tech.easyflow.common.constant.enums.EnumDataStatus;
import tech.easyflow.common.entity.LoginAccount;
import tech.easyflow.common.web.exceptions.BusinessException;
import tech.easyflow.system.enums.CategoryResourceType;
import tech.easyflow.system.enums.ResourceAction;
import tech.easyflow.system.service.ResourceAccessService;
import java.math.BigInteger;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
/**
* {@link WorkflowUsageAuthorizationService} 工作流使用权限校验测试。
*/
public class WorkflowUsageAuthorizationServiceTest {
/**
* 验证禁用工作流即使资源权限允许也不能被使用。
*/
@Test
public void shouldRejectDisabledWorkflow() {
BigInteger workflowId = BigInteger.valueOf(101);
WorkflowService workflowService = mock(WorkflowService.class);
ResourceAccessService resourceAccessService = mock(ResourceAccessService.class);
Workflow workflow = workflow(workflowId, BigInteger.TEN, EnumDataStatus.UNAVAILABLE.getCode());
LoginAccount account = account(BigInteger.ONE, BigInteger.TEN);
when(workflowService.getById(workflowId)).thenReturn(workflow);
when(resourceAccessService.canAccess(
account,
CategoryResourceType.WORKFLOW,
workflow,
ResourceAction.USE)).thenReturn(true);
WorkflowUsageAuthorizationService service =
new WorkflowUsageAuthorizationService(workflowService, resourceAccessService);
BusinessException exception = Assert.assertThrows(
BusinessException.class,
() -> service.requireUsableWorkflow(workflowId, account, "工作流不可用")
);
Assert.assertEquals(exception.getMessage(), "工作流不可用");
}
/**
* 验证工作流与账号租户不一致时拒绝使用。
*/
@Test
public void shouldRejectCrossTenantWorkflow() {
BigInteger workflowId = BigInteger.valueOf(102);
WorkflowService workflowService = mock(WorkflowService.class);
ResourceAccessService resourceAccessService = mock(ResourceAccessService.class);
Workflow workflow = workflow(
workflowId,
BigInteger.valueOf(20),
EnumDataStatus.AVAILABLE.getCode());
LoginAccount account = account(BigInteger.ONE, BigInteger.TEN);
when(workflowService.getById(workflowId)).thenReturn(workflow);
WorkflowUsageAuthorizationService service =
new WorkflowUsageAuthorizationService(workflowService, resourceAccessService);
Assert.assertThrows(
BusinessException.class,
() -> service.requireUsableWorkflow(workflowId, account, "工作流不可用")
);
}
/**
* 验证启用、同租户且具有使用权限的工作流可以返回。
*/
@Test
public void shouldReturnUsableWorkflow() {
BigInteger workflowId = BigInteger.valueOf(103);
WorkflowService workflowService = mock(WorkflowService.class);
ResourceAccessService resourceAccessService = mock(ResourceAccessService.class);
Workflow workflow = workflow(
workflowId,
BigInteger.TEN,
EnumDataStatus.AVAILABLE.getCode());
LoginAccount account = account(BigInteger.ONE, BigInteger.TEN);
when(workflowService.getById(workflowId)).thenReturn(workflow);
when(resourceAccessService.canAccess(
account,
CategoryResourceType.WORKFLOW,
workflow,
ResourceAction.USE)).thenReturn(true);
WorkflowUsageAuthorizationService service =
new WorkflowUsageAuthorizationService(workflowService, resourceAccessService);
Workflow result = service.requireUsableWorkflow(workflowId, account, "工作流不可用");
Assert.assertSame(result, workflow);
}
/**
* 创建工作流测试数据。
*
* @param id 工作流 ID
* @param tenantId 租户 ID
* @param status 工作流状态
* @return 工作流
*/
private Workflow workflow(BigInteger id, BigInteger tenantId, Integer status) {
Workflow workflow = new Workflow();
workflow.setId(id);
workflow.setTenantId(tenantId);
workflow.setStatus(status);
return workflow;
}
/**
* 创建登录账号测试数据。
*
* @param id 账号 ID
* @param tenantId 租户 ID
* @return 登录账号
*/
private LoginAccount account(BigInteger id, BigInteger tenantId) {
LoginAccount account = new LoginAccount();
account.setId(id);
account.setTenantId(tenantId);
return account;
}
}

View File

@@ -61,6 +61,11 @@ public class WorkflowSharePolicyTest {
"/api/v1/workflow/detail", "/api/v1/workflow/detail",
ResourceAction.READ ResourceAction.READ
)); ));
Assert.assertTrue(WorkflowSharePolicy.isAllowedRequest(
"GET",
"/api/v1/workflow/designer/childWorkflow",
ResourceAction.READ
));
Assert.assertTrue(WorkflowSharePolicy.isAllowedRequest( Assert.assertTrue(WorkflowSharePolicy.isAllowedRequest(
"POST", "POST",
"/api/v1/workflow/update", "/api/v1/workflow/update",

View File

@@ -42,5 +42,17 @@
<groupId>tech.easyflow</groupId> <groupId>tech.easyflow</groupId>
<artifactId>easyflow-module-ai</artifactId> <artifactId>easyflow-module-ai</artifactId>
</dependency> </dependency>
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-core</artifactId>
<version>5.12.0</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>junit</groupId>
<artifactId>junit</artifactId>
<version>${junit.version}</version>
<scope>test</scope>
</dependency>
</dependencies> </dependencies>
</project> </project>

View File

@@ -0,0 +1,159 @@
package tech.easyflow.job.service;
import cn.hutool.core.bean.BeanUtil;
import com.alibaba.fastjson2.JSONObject;
import com.easyagents.flow.core.chain.runtime.ChainExecutor;
import com.mybatisflex.core.tenant.TenantManager;
import org.springframework.stereotype.Service;
import tech.easyflow.ai.service.WorkflowUsageAuthorizationService;
import tech.easyflow.common.constant.Constants;
import tech.easyflow.common.constant.enums.EnumDataStatus;
import tech.easyflow.common.constant.enums.EnumJobStatus;
import tech.easyflow.common.entity.LoginAccount;
import tech.easyflow.job.entity.SysJob;
import tech.easyflow.job.job.JobConstant;
import tech.easyflow.job.support.SysJobWorkflowReferenceSupport;
import tech.easyflow.system.entity.SysAccount;
import tech.easyflow.system.service.SysAccountService;
import java.math.BigInteger;
import java.util.Map;
import java.util.Objects;
/**
* 工作流定时任务执行服务。
*
* <p>每次触发都重新加载任务、账号和工作流,并按服务端记录恢复执行主体及重新授权。</p>
*/
@Service
public class WorkflowJobExecutionService {
/** 定时任务服务。 */
private final SysJobService sysJobService;
/** 系统账号服务。 */
private final SysAccountService sysAccountService;
/** 工作流使用权限校验服务。 */
private final WorkflowUsageAuthorizationService workflowUsageAuthorizationService;
/** 工作流执行器。 */
private final ChainExecutor chainExecutor;
/**
* 创建工作流定时任务执行服务。
*
* @param sysJobService 定时任务服务
* @param sysAccountService 系统账号服务
* @param workflowUsageAuthorizationService 工作流使用权限校验服务
* @param chainExecutor 工作流执行器
*/
public WorkflowJobExecutionService(
SysJobService sysJobService,
SysAccountService sysAccountService,
WorkflowUsageAuthorizationService workflowUsageAuthorizationService,
ChainExecutor chainExecutor) {
this.sysJobService = sysJobService;
this.sysAccountService = sysAccountService;
this.workflowUsageAuthorizationService = workflowUsageAuthorizationService;
this.chainExecutor = chainExecutor;
}
/**
* 使用当前数据库状态执行工作流定时任务。
*
* @param scheduledJob Quartz 中保存的任务快照
* @return 工作流执行结果
* @throws IllegalStateException 任务、账号或租户状态非法时抛出
*/
public Object execute(SysJob scheduledJob) {
if (scheduledJob == null || scheduledJob.getId() == null) {
throw new IllegalStateException("定时任务不存在或缺少ID");
}
return TenantManager.withoutTenantCondition(
() -> executeWithoutTenantCondition(
scheduledJob.getId(),
scheduledJob.getTenantId()));
}
/**
* 在已关闭 ORM 租户条件的作用域中执行任务,并显式完成租户边界校验。
*
* @param jobId 定时任务 ID
* @param scheduledTenantId Quartz 任务快照中的租户 ID
* @return 工作流执行结果
* @throws IllegalStateException 任务、账号或租户状态非法时抛出
*/
private Object executeWithoutTenantCondition(
BigInteger jobId,
BigInteger scheduledTenantId) {
SysJob job = sysJobService.getById(jobId);
if (job == null) {
throw new IllegalStateException("定时任务不存在或已删除id=" + jobId);
}
if (scheduledTenantId == null
|| job.getTenantId() == null
|| !Objects.equals(scheduledTenantId, job.getTenantId())) {
throw new IllegalStateException("定时任务租户信息不一致id=" + jobId);
}
if (!Integer.valueOf(EnumJobStatus.RUNNING.getCode()).equals(job.getStatus())) {
throw new IllegalStateException("定时任务未处于运行状态id=" + jobId);
}
if (!SysJobWorkflowReferenceSupport.isWorkflowJob(job)) {
throw new IllegalStateException("定时任务类型已变更id=" + jobId);
}
SysAccount account = requireAvailableOwner(job);
LoginAccount loginAccount = new LoginAccount();
BeanUtil.copyProperties(account, loginAccount);
BigInteger workflowId = SysJobWorkflowReferenceSupport.requireWorkflowId(job);
workflowUsageAuthorizationService.requireUsableWorkflow(
workflowId,
loginAccount,
"定时任务关联的工作流不存在、已禁用或无权运行");
JSONObject workflowParams = resolveWorkflowParams(job.getJobParams());
workflowParams.put(Constants.LOGIN_USER_KEY, loginAccount);
return chainExecutor.execute(workflowId.toString(), workflowParams);
}
/**
* 获取任务创建账号并校验账号仍可用于执行任务。
*
* @param job 当前数据库中的定时任务
* @return 可用的任务创建账号
* @throws IllegalStateException 创建账号缺失、禁用或跨租户时抛出
*/
private SysAccount requireAvailableOwner(SysJob job) {
BigInteger accountId = job.getCreatedBy();
if (accountId == null) {
throw new IllegalStateException("定时任务缺少服务端归属账号id=" + job.getId());
}
SysAccount account = sysAccountService.getById(accountId);
if (account == null) {
throw new IllegalStateException("定时任务归属账号不存在id=" + accountId);
}
if (!EnumDataStatus.AVAILABLE.getCode().equals(account.getStatus())) {
throw new IllegalStateException("定时任务归属账号未启用id=" + accountId);
}
if (!Objects.equals(job.getTenantId(), account.getTenantId())) {
throw new IllegalStateException("定时任务与归属账号租户不一致id=" + job.getId());
}
return account;
}
/**
* 解析工作流运行参数并返回可写对象。
*
* @param jobParams 定时任务参数
* @return 工作流运行参数
*/
private JSONObject resolveWorkflowParams(Map<String, Object> jobParams) {
if (jobParams == null) {
return new JSONObject();
}
JSONObject params = new JSONObject(jobParams)
.getJSONObject(JobConstant.WORKFLOW_PARAMS_KEY);
return params == null ? new JSONObject() : new JSONObject(params);
}
}

View File

@@ -2,24 +2,15 @@ package tech.easyflow.job.util;
import cn.hutool.core.util.ArrayUtil; import cn.hutool.core.util.ArrayUtil;
import cn.hutool.core.util.StrUtil; import cn.hutool.core.util.StrUtil;
import com.alibaba.fastjson2.JSONObject;
import com.mybatisflex.core.tenant.TenantManager;
import com.easyagents.flow.core.chain.ChainDefinition;
import com.easyagents.flow.core.chain.runtime.ChainExecutor;
import org.quartz.JobKey; import org.quartz.JobKey;
import org.quartz.TriggerKey; import org.quartz.TriggerKey;
import tech.easyflow.common.constant.Constants;
import tech.easyflow.common.constant.enums.EnumJobType; import tech.easyflow.common.constant.enums.EnumJobType;
import tech.easyflow.common.satoken.util.SaTokenUtil;
import tech.easyflow.common.util.SpringContextUtil; import tech.easyflow.common.util.SpringContextUtil;
import tech.easyflow.job.entity.SysJob; import tech.easyflow.job.entity.SysJob;
import tech.easyflow.job.job.JobConstant; import tech.easyflow.job.job.JobConstant;
import tech.easyflow.job.support.SysJobWorkflowReferenceSupport; import tech.easyflow.job.service.WorkflowJobExecutionService;
import tech.easyflow.system.entity.SysAccount;
import tech.easyflow.system.service.SysAccountService;
import java.lang.reflect.Method; import java.lang.reflect.Method;
import java.math.BigInteger;
import java.util.Arrays; import java.util.Arrays;
import java.util.Map; import java.util.Map;
@@ -68,34 +59,16 @@ public class JobUtil {
return null; return null;
} }
/**
* 通过任务模块的受控执行服务运行工作流。
*
* @param job Quartz 中保存的任务快照
* @return 工作流执行结果
*/
public static Object execWorkFlow(SysJob job) { public static Object execWorkFlow(SysJob job) {
Map<String, Object> jobParams = job.getJobParams(); WorkflowJobExecutionService executionService =
JSONObject obj = new JSONObject(jobParams); SpringContextUtil.getBean(WorkflowJobExecutionService.class);
BigInteger workflowId = SysJobWorkflowReferenceSupport.requireWorkflowId(job); return executionService.execute(job);
JSONObject params = obj.getJSONObject(JobConstant.WORKFLOW_PARAMS_KEY);
ChainExecutor executor = SpringContextUtil.getBean(ChainExecutor.class);
Object accountId = obj.get(JobConstant.ACCOUNT_ID);
SysAccountService accountService = SpringContextUtil.getBean(SysAccountService.class);
try {
TenantManager.ignoreTenantCondition();
ChainDefinition chain = executor.getDefinitionRepository().getChainDefinitionById(workflowId.toString());
if (chain == null) {
throw new IllegalStateException("定时任务关联的工作流不存在或已删除id=" + workflowId);
}
if (accountId != null) {
// 设置的归属者
SysAccount account = accountService.getById(accountId.toString());
if (account != null) {
params.put(Constants.LOGIN_USER_KEY, SaTokenUtil.getLoginAccount());
}
}
return executor.execute(workflowId.toString(), params);
} finally {
TenantManager.restoreTenantCondition();
}
} }
public static Object execute(SysJob job) { public static Object execute(SysJob job) {

View File

@@ -0,0 +1,217 @@
package tech.easyflow.job.service;
import com.easyagents.flow.core.chain.runtime.ChainExecutor;
import org.junit.Assert;
import org.junit.Test;
import org.mockito.ArgumentCaptor;
import tech.easyflow.ai.service.WorkflowUsageAuthorizationService;
import tech.easyflow.common.constant.Constants;
import tech.easyflow.common.constant.enums.EnumDataStatus;
import tech.easyflow.common.constant.enums.EnumJobStatus;
import tech.easyflow.common.constant.enums.EnumJobType;
import tech.easyflow.common.entity.LoginAccount;
import tech.easyflow.common.web.exceptions.BusinessException;
import tech.easyflow.job.entity.SysJob;
import tech.easyflow.job.job.JobConstant;
import tech.easyflow.system.entity.SysAccount;
import tech.easyflow.system.service.SysAccountService;
import java.math.BigInteger;
import java.util.Map;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyMap;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* {@link WorkflowJobExecutionService} 运行时授权测试。
*/
public class WorkflowJobExecutionServiceTest {
/**
* 验证每次触发都会按数据库当前状态授权,并将任务创建账号注入工作流参数。
*/
@Test
public void shouldReauthorizeAndRestoreServerControlledOwner() {
BigInteger jobId = BigInteger.valueOf(101);
BigInteger tenantId = BigInteger.valueOf(201);
BigInteger accountId = BigInteger.valueOf(301);
BigInteger workflowId = BigInteger.valueOf(401);
SysJobService jobService = mock(SysJobService.class);
SysAccountService accountService = mock(SysAccountService.class);
WorkflowUsageAuthorizationService authorizationService =
mock(WorkflowUsageAuthorizationService.class);
ChainExecutor chainExecutor = mock(ChainExecutor.class);
SysJob currentJob = workflowJob(jobId, tenantId, accountId, workflowId);
SysAccount account = account(accountId, tenantId, EnumDataStatus.AVAILABLE.getCode());
when(jobService.getById(jobId)).thenReturn(currentJob);
when(accountService.getById(accountId)).thenReturn(account);
Map<String, Object> executionResult = Map.of("status", "done");
when(chainExecutor.execute(eq(workflowId.toString()), anyMap()))
.thenReturn(executionResult);
WorkflowJobExecutionService service = new WorkflowJobExecutionService(
jobService,
accountService,
authorizationService,
chainExecutor);
Object result = service.execute(scheduledJob(jobId, tenantId));
Assert.assertSame(executionResult, result);
ArgumentCaptor<LoginAccount> accountCaptor =
ArgumentCaptor.forClass(LoginAccount.class);
verify(authorizationService).requireUsableWorkflow(
eq(workflowId),
accountCaptor.capture(),
anyString());
Assert.assertEquals(accountCaptor.getValue().getId(), accountId);
Assert.assertEquals(accountCaptor.getValue().getTenantId(), tenantId);
@SuppressWarnings("unchecked")
ArgumentCaptor<Map<String, Object>> paramsCaptor =
ArgumentCaptor.forClass(Map.class);
verify(chainExecutor).execute(eq(workflowId.toString()), paramsCaptor.capture());
Object loginUser = paramsCaptor.getValue().get(Constants.LOGIN_USER_KEY);
Assert.assertTrue(loginUser instanceof LoginAccount);
Assert.assertEquals(((LoginAccount) loginUser).getId(), accountId);
}
/**
* 验证权限已撤销时执行器不会启动工作流。
*/
@Test
public void shouldRejectExecutionAfterPermissionRevoked() {
BigInteger jobId = BigInteger.valueOf(102);
BigInteger tenantId = BigInteger.valueOf(202);
BigInteger accountId = BigInteger.valueOf(302);
BigInteger workflowId = BigInteger.valueOf(402);
SysJobService jobService = mock(SysJobService.class);
SysAccountService accountService = mock(SysAccountService.class);
WorkflowUsageAuthorizationService authorizationService =
mock(WorkflowUsageAuthorizationService.class);
ChainExecutor chainExecutor = mock(ChainExecutor.class);
when(jobService.getById(jobId))
.thenReturn(workflowJob(jobId, tenantId, accountId, workflowId));
when(accountService.getById(accountId))
.thenReturn(account(accountId, tenantId, EnumDataStatus.AVAILABLE.getCode()));
when(authorizationService.requireUsableWorkflow(
eq(workflowId),
any(LoginAccount.class),
anyString()))
.thenThrow(new BusinessException("工作流权限已撤销"));
WorkflowJobExecutionService service = new WorkflowJobExecutionService(
jobService,
accountService,
authorizationService,
chainExecutor);
BusinessException exception = Assert.assertThrows(
BusinessException.class,
() -> service.execute(scheduledJob(jobId, tenantId))
);
Assert.assertTrue(exception.getMessage().contains("权限已撤销"));
verify(chainExecutor, never()).execute(anyString(), anyMap());
}
/**
* 验证任务快照与数据库租户不一致时执行失败。
*/
@Test
public void shouldRejectCrossTenantJobSnapshot() {
BigInteger jobId = BigInteger.valueOf(103);
BigInteger tenantId = BigInteger.valueOf(203);
SysJobService jobService = mock(SysJobService.class);
SysAccountService accountService = mock(SysAccountService.class);
WorkflowUsageAuthorizationService authorizationService =
mock(WorkflowUsageAuthorizationService.class);
ChainExecutor chainExecutor = mock(ChainExecutor.class);
when(jobService.getById(jobId)).thenReturn(workflowJob(
jobId,
tenantId,
BigInteger.valueOf(303),
BigInteger.valueOf(403)));
WorkflowJobExecutionService service = new WorkflowJobExecutionService(
jobService,
accountService,
authorizationService,
chainExecutor);
IllegalStateException exception = Assert.assertThrows(
IllegalStateException.class,
() -> service.execute(scheduledJob(jobId, BigInteger.valueOf(999)))
);
Assert.assertTrue(exception.getMessage().contains("租户"));
verify(authorizationService, never()).requireUsableWorkflow(
any(BigInteger.class),
any(LoginAccount.class),
anyString());
}
/**
* 创建 Quartz 任务快照。
*
* @param jobId 定时任务 ID
* @param tenantId 租户 ID
* @return 任务快照
*/
private SysJob scheduledJob(BigInteger jobId, BigInteger tenantId) {
SysJob job = new SysJob();
job.setId(jobId);
job.setTenantId(tenantId);
job.setJobType(EnumJobType.TINY_FLOW.getCode());
return job;
}
/**
* 创建数据库中的工作流定时任务。
*
* @param jobId 定时任务 ID
* @param tenantId 租户 ID
* @param accountId 任务创建账号 ID
* @param workflowId 工作流 ID
* @return 工作流定时任务
*/
private SysJob workflowJob(
BigInteger jobId,
BigInteger tenantId,
BigInteger accountId,
BigInteger workflowId) {
SysJob job = new SysJob();
job.setId(jobId);
job.setTenantId(tenantId);
job.setCreatedBy(accountId);
job.setStatus(EnumJobStatus.RUNNING.getCode());
job.setJobType(EnumJobType.TINY_FLOW.getCode());
job.setJobParams(Map.of(
JobConstant.WORKFLOW_KEY, workflowId.toString(),
JobConstant.WORKFLOW_PARAMS_KEY, Map.of("question", "hello")
));
return job;
}
/**
* 创建系统账号。
*
* @param accountId 账号 ID
* @param tenantId 租户 ID
* @param status 账号状态
* @return 系统账号
*/
private SysAccount account(
BigInteger accountId,
BigInteger tenantId,
Integer status) {
SysAccount account = new SysAccount();
account.setId(accountId);
account.setTenantId(tenantId);
account.setStatus(status);
return account;
}
}

View File

@@ -2,52 +2,38 @@ package tech.easyflow.system.config;
import tech.easyflow.common.util.SpringContextUtil; import tech.easyflow.common.util.SpringContextUtil;
import tech.easyflow.common.dict.DictManager; import tech.easyflow.common.dict.DictManager;
import tech.easyflow.common.dict.loader.DbDataLoader;
import tech.easyflow.system.entity.SysDict; import tech.easyflow.system.entity.SysDict;
import tech.easyflow.system.mapper.*;
import tech.easyflow.system.service.SysDictService; import tech.easyflow.system.service.SysDictService;
import org.springframework.boot.context.event.ApplicationReadyEvent; import org.springframework.boot.context.event.ApplicationReadyEvent;
import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Configuration;
import org.springframework.context.event.EventListener; import org.springframework.context.event.EventListener;
import javax.annotation.Resource;
import java.util.List; import java.util.List;
/**
* 注册由字典表维护的静态业务字典。
*/
@Configuration @Configuration
public class SysDictAutoConfig { public class SysDictAutoConfig {
private SysDictService service; private final SysDictService service;
@Resource
private SysMenuMapper sysMenuMapper;
@Resource
private SysDeptMapper sysDeptMapper;
@Resource
private SysRoleMapper sysRoleMapper;
@Resource
private SysPositionMapper sysPositionMapper;
@Resource
private SysAccountMapper sysAccountMapper;
/**
* 创建系统字典自动配置。
*
* @param service 系统字典服务
*/
public SysDictAutoConfig(SysDictService service) { public SysDictAutoConfig(SysDictService service) {
this.service = service; this.service = service;
} }
/**
* 应用启动完成后注册静态字典。
*/
@EventListener(ApplicationReadyEvent.class) @EventListener(ApplicationReadyEvent.class)
public void onApplicationStartup() { public void onApplicationStartup() {
DictManager dictManager = SpringContextUtil.getBean(DictManager.class); DictManager dictManager = SpringContextUtil.getBean(DictManager.class);
// 菜单表字典
dictManager.putLoader(new DbDataLoader<>("sysMenu", sysMenuMapper, "id", "menu_title", "parent_id", "sort_no asc", false));
// 部门表字典
dictManager.putLoader(new DbDataLoader<>("sysDept", sysDeptMapper, "id", "dept_name", "parent_id", "sort_no asc", false));
// 角色表字典
dictManager.putLoader(new DbDataLoader<>("sysRole", sysRoleMapper, "id", "role_name", null, null, true));
// 职位字典
dictManager.putLoader(new DbDataLoader<>("sysPosition", sysPositionMapper, "id", "position_name", null, null, true));
// 用户字典
dictManager.putLoader(new DbDataLoader<>("sysAccount", sysAccountMapper, "id", "login_name", null, null, true));
List<SysDict> sysDicts = service.list(); List<SysDict> sysDicts = service.list();
if (sysDicts != null) { if (sysDicts != null) {
sysDicts.forEach(sysDict -> dictManager.putLoader(sysDict.buildLoader())); sysDicts.forEach(sysDict -> dictManager.putLoader(sysDict.buildLoader()));

View File

@@ -0,0 +1,27 @@
SET NAMES utf8mb4;
INSERT INTO `tb_sys_menu` (
`id`, `parent_id`, `menu_type`, `menu_title`, `menu_url`, `component`, `menu_icon`,
`is_show`, `permission_tag`, `sort_no`, `status`, `created`, `created_by`, `modified`, `modified_by`, `remark`
)
VALUES (
399900000000000001, 366200000000000003, 1, '查询', '', '', '',
0, '/api/v1/chatHistory/query', 1, 0, NOW(), 1, NOW(), 1, '聊天历史查询权限'
);
INSERT INTO `tb_sys_role_menu` (`id`, `role_id`, `menu_id`)
SELECT
399900000000000100 + ROW_NUMBER() OVER (ORDER BY candidate.`role_id`),
candidate.`role_id`,
399900000000000001
FROM (
SELECT DISTINCT parent_grant.`role_id`
FROM `tb_sys_role_menu` parent_grant
WHERE parent_grant.`menu_id` = 366200000000000003
) candidate
WHERE NOT EXISTS (
SELECT 1
FROM `tb_sys_role_menu` existing_grant
WHERE existing_grant.`role_id` = candidate.`role_id`
AND existing_grant.`menu_id` = 399900000000000001
);

View File

@@ -78,7 +78,7 @@ export interface AgentChatCapabilityPayload {
} }
export function getPublishedAgents() { export function getPublishedAgents() {
return api.get<RequestResult<AgentInfo[]>>('/api/v1/agent/options', { return api.get<RequestResult<AgentInfo[]>>('/api/v1/agent/session/options', {
params: { publishedOnly: true }, params: { publishedOnly: true },
}); });
} }
@@ -95,7 +95,7 @@ export function getAgentSession(sessionId: number | string) {
export function getPublishedKnowledges() { export function getPublishedKnowledges() {
return api.get<RequestResult<AgentChatKnowledgeView[]>>( return api.get<RequestResult<AgentChatKnowledgeView[]>>(
'/api/v1/agent/knowledgeOptions', '/api/v1/agent/session/knowledgeOptions',
); );
} }

View File

@@ -6,7 +6,7 @@ import pageSource from './index.vue?raw';
describe('管理端智能体聊天历史契约', () => { describe('管理端智能体聊天历史契约', () => {
it('使用智能体候选接口并保留 assistantId 查询参数', () => { it('使用智能体候选接口并保留 assistantId 查询参数', () => {
expect(pageSource).toContain("'/api/v1/agent/options'"); expect(pageSource).toContain("'/api/v1/chatHistory/agentOptions'");
expect(pageSource).not.toContain('/api/v1/bot/list'); expect(pageSource).not.toContain('/api/v1/bot/list');
expect(pageSource).toContain('label: item.name'); expect(pageSource).toContain('label: item.name');
expect(pageSource).toContain('assistantId: query.value.assistantId'); expect(pageSource).toContain('assistantId: query.value.assistantId');

View File

@@ -103,7 +103,9 @@ onMounted(async () => {
async function fetchAgents() { async function fetchAgents() {
agentLoading.value = true; agentLoading.value = true;
const [error, res] = await tryit(api.get)('/api/v1/agent/options'); const [error, res] = await tryit(api.get)(
'/api/v1/chatHistory/agentOptions',
);
agentLoading.value = false; agentLoading.value = false;
if (error || res?.errorCode !== 0) { if (error || res?.errorCode !== 0) {
agentOptions.value = []; agentOptions.value = [];

View File

@@ -19,7 +19,6 @@ import {
} from 'element-plus'; } from 'element-plus';
import { api } from '#/api/request'; import { api } from '#/api/request';
import DictSelect from '#/components/dict/DictSelect.vue';
import UploadAvatar from '#/components/upload/UploadAvatar.vue'; import UploadAvatar from '#/components/upload/UploadAvatar.vue';
import { $t } from '#/locales'; import { $t } from '#/locales';
@@ -102,6 +101,8 @@ const normalizeEntity = (raw: any = {}) => {
const entity = ref<any>(normalizeEntity(defaultEntity)); const entity = ref<any>(normalizeEntity(defaultEntity));
const btnLoading = ref(false); const btnLoading = ref(false);
const categoryOptions = ref<any[]>([]);
let categoryOptionsLoaded = false;
const rules = ref({ const rules = ref({
collectionType: [ collectionType: [
{ required: true, message: $t('message.required'), trigger: 'change' }, { required: true, message: $t('message.required'), trigger: 'change' },
@@ -145,7 +146,7 @@ const collectionTypeList = [
}, },
]; ];
function openDialog(row: any = {}) { async function openDialog(row: any = {}) {
if (row.id) { if (row.id) {
isAdd.value = false; isAdd.value = false;
entity.value = normalizeEntity(row); entity.value = normalizeEntity(row);
@@ -154,6 +155,17 @@ function openDialog(row: any = {}) {
entity.value = normalizeEntity(defaultEntity); entity.value = normalizeEntity(defaultEntity);
} }
dialogVisible.value = true; dialogVisible.value = true;
if (!categoryOptionsLoaded) {
try {
const res = await api.get(
'/api/v1/documentCollectionCategory/visibleList',
);
categoryOptions.value = Array.isArray(res.data) ? res.data : [];
categoryOptionsLoaded = true;
} catch {
categoryOptions.value = [];
}
}
} }
async function save() { async function save() {
@@ -251,10 +263,14 @@ defineExpose({
prop="categoryId" prop="categoryId"
:label="$t('documentCollection.categoryId')" :label="$t('documentCollection.categoryId')"
> >
<DictSelect <ElSelect v-model="entity.categoryId" clearable filterable>
v-model="entity.categoryId" <ElOption
dict-code="aiDocumentCollectionCategory" v-for="category in categoryOptions"
:key="category.id"
:label="category.categoryName"
:value="category.id"
/> />
</ElSelect>
</ElFormItem> </ElFormItem>
<ElFormItem <ElFormItem
prop="visibilityScope" prop="visibilityScope"

View File

@@ -375,15 +375,9 @@ const loadModels = async (preferredId?: string) => {
const loadPublishBaseUrl = async () => { const loadPublishBaseUrl = async () => {
try { try {
const res = await api.get('/api/v1/sysOption/list', { const res = await api.get('/api/v1/model/gatewayConfig');
params: {
keys: ['chat_publish_base_url'],
},
});
if (res.errorCode === 0) { if (res.errorCode === 0) {
publishBaseUrl.value = String( publishBaseUrl.value = String(res.data?.publishBaseUrl || '').trim();
res.data?.chat_publish_base_url || '',
).trim();
} }
} catch { } catch {
publishBaseUrl.value = ''; publishBaseUrl.value = '';

View File

@@ -1,6 +1,13 @@
<script setup lang="ts"> <script setup lang="ts">
import type {FormInstance} from 'element-plus'; import type {FormInstance} from 'element-plus';
import {ElForm, ElFormItem, ElInput, ElMessage} from 'element-plus'; import {
ElForm,
ElFormItem,
ElInput,
ElMessage,
ElOption,
ElSelect,
} from 'element-plus';
import {onMounted, ref} from 'vue'; import {onMounted, ref} from 'vue';
@@ -37,6 +44,8 @@ const dialogVisible = ref(false);
const isAdd = ref(true); const isAdd = ref(true);
const entity = ref<any>(createDefaultEntity()); const entity = ref<any>(createDefaultEntity());
const btnLoading = ref(false); const btnLoading = ref(false);
const categoryOptions = ref<any[]>([]);
let categoryOptionsLoaded = false;
const rules = ref({ const rules = ref({
resourceType: [ resourceType: [
{ required: true, message: $t('message.required'), trigger: 'change' }, { required: true, message: $t('message.required'), trigger: 'change' },
@@ -57,7 +66,7 @@ const rules = ref({
}); });
// functions // functions
function openDialog(row: any) { async function openDialog(row: any) {
isAdd.value = !row?.id; isAdd.value = !row?.id;
entity.value = { entity.value = {
...createDefaultEntity(), ...createDefaultEntity(),
@@ -65,6 +74,15 @@ function openDialog(row: any) {
status: row?.status ?? 0, status: row?.status ?? 0,
}; };
dialogVisible.value = true; dialogVisible.value = true;
if (!categoryOptionsLoaded) {
try {
const res = await api.get('/api/v1/resourceCategory/visibleList');
categoryOptions.value = Array.isArray(res.data) ? res.data : [];
categoryOptionsLoaded = true;
} catch {
categoryOptions.value = [];
}
}
} }
function save() { function save() {
saveForm.value?.validate((valid) => { saveForm.value?.validate((valid) => {
@@ -158,10 +176,14 @@ function uploadError() {
<ElInput v-model.trim="entity.resourceName" /> <ElInput v-model.trim="entity.resourceName" />
</ElFormItem> </ElFormItem>
<ElFormItem prop="categoryId" :label="$t('aiResource.categoryId')"> <ElFormItem prop="categoryId" :label="$t('aiResource.categoryId')">
<DictSelect <ElSelect v-model="entity.categoryId" clearable filterable>
v-model="entity.categoryId" <ElOption
dict-code="aiResourceCategory" v-for="category in categoryOptions"
:key="category.id"
:label="category.categoryName"
:value="category.id"
/> />
</ElSelect>
</ElFormItem> </ElFormItem>
</ElForm> </ElForm>
</EasyFlowFormModal> </EasyFlowFormModal>

View File

@@ -151,9 +151,7 @@ async function initializeWorkflow() {
} }
await Promise.all([ await Promise.all([
loadCustomNode(), loadCustomNode(),
getLlmList(), getDesignerOptions(),
getKnowledgeList(),
getCodeEngineList(),
getWorkflowInfo(workflowId.value), getWorkflowInfo(workflowId.value),
]); ]);
showTinyFlow.value = true; showTinyFlow.value = true;
@@ -574,24 +572,14 @@ function reconcileWorkflowDraftAfterSave(savedContentSignature: string) {
pendingDraftContent = normalizedContent; pendingDraftContent = normalizedContent;
persistPendingWorkflowDraft(); persistPendingWorkflowDraft();
} }
async function getLlmList() { async function getDesignerOptions() {
return api.get('/api/v1/model/list').then((res) => { return api.get('/api/v1/workflow/designer/options').then((res) => {
llmList.value = res.data; llmList.value = Array.isArray(res.data?.models) ? res.data.models : [];
}); knowledgeList.value = Array.isArray(res.data?.knowledges)
} ? res.data.knowledges
async function getKnowledgeList() { : [];
return api.get('/api/v1/documentCollection/list').then((res) => { if (Array.isArray(res.data?.codeEngines) && res.data.codeEngines.length > 0) {
knowledgeList.value = res.data; codeEngineList.value = res.data.codeEngines;
});
}
async function getCodeEngineList() {
return api.get('/api/v1/workflow/supportedCodeEngines').then((res) => {
if (
res?.errorCode === 0 &&
Array.isArray(res.data) &&
res.data.length > 0
) {
codeEngineList.value = res.data;
} }
}); });
} }
@@ -785,7 +773,7 @@ function handleChoose(nodeName: string, value: any) {
function handleWorkflowNodeUpdate(chooseId: any) { function handleWorkflowNodeUpdate(chooseId: any) {
pageLoading.value = true; pageLoading.value = true;
api api
.get('/api/v1/workflowNode/getChainParams', { .get('/api/v1/workflow/designer/childWorkflow', {
params: { params: {
currentId: workflowId.value, currentId: workflowId.value,
workflowId: chooseId, workflowId: chooseId,
@@ -799,7 +787,7 @@ function handleWorkflowNodeUpdate(chooseId: any) {
function handlePluginNodeUpdate(chooseId: any) { function handlePluginNodeUpdate(chooseId: any) {
pageLoading.value = true; pageLoading.value = true;
api api
.get('/api/v1/pluginItem/getTinyFlowData', { .get('/api/v1/workflow/designer/pluginTinyFlow', {
params: { params: {
id: chooseId, id: chooseId,
}, },
@@ -848,7 +836,7 @@ function onAsyncExecute(info: any) {
:title="$t('menus.ai.plugin')" :title="$t('menus.ai.plugin')"
width="730" width="730"
ref="pluginSelectRef" ref="pluginSelectRef"
page-url="/api/v1/plugin/page?availableOnly=true" page-url="/api/v1/workflow/designer/plugins"
:has-parent="true" :has-parent="true"
single-select single-select
@get-data="(v) => handleChoose(nodeNames.pluginNode, v)" @get-data="(v) => handleChoose(nodeNames.pluginNode, v)"

View File

@@ -201,7 +201,7 @@ const actions: ActionButton[] = [
icon: Share, icon: Share,
text: $t('button.share'), text: $t('button.share'),
permission: '/api/v1/workflow/save', permission: '/api/v1/workflow/save',
placement: 'inline', placement: 'menu',
disabled: (row: any) => disabled: (row: any) =>
row.publishStatus !== 'PUBLISHED' || sharingWorkflowId.value === row.id, row.publishStatus !== 'PUBLISHED' || sharingWorkflowId.value === row.id,
loading: (row: any) => sharingWorkflowId.value === row.id, loading: (row: any) => sharingWorkflowId.value === row.id,

View File

@@ -49,6 +49,8 @@ const createDefaultEntity = () => ({
}); });
const entity = ref<any>(createDefaultEntity()); const entity = ref<any>(createDefaultEntity());
const btnLoading = ref(false); const btnLoading = ref(false);
const categoryOptions = ref<any[]>([]);
let categoryOptionsLoaded = false;
const visibilityScopeOptions = computed(() => [ const visibilityScopeOptions = computed(() => [
{ {
label: $t('aiWorkflow.visibilityScopePrivate'), label: $t('aiWorkflow.visibilityScopePrivate'),
@@ -88,7 +90,7 @@ const rules = computed(() => ({
}), }),
})); }));
// functions // functions
function openDialog(row: any, importMode = false) { async function openDialog(row: any, importMode = false) {
isImport.value = importMode; isImport.value = importMode;
isAdd.value = !row?.id; isAdd.value = !row?.id;
entity.value = { entity.value = {
@@ -96,6 +98,15 @@ function openDialog(row: any, importMode = false) {
...row, ...row,
}; };
dialogVisible.value = true; dialogVisible.value = true;
if (!categoryOptionsLoaded) {
try {
const res = await api.get('/api/v1/workflowCategory/visibleList');
categoryOptions.value = Array.isArray(res.data) ? res.data : [];
categoryOptionsLoaded = true;
} catch {
categoryOptions.value = [];
}
}
} }
const beforeUpload: UploadProps['beforeUpload'] = (file) => { const beforeUpload: UploadProps['beforeUpload'] = (file) => {
@@ -227,10 +238,14 @@ function closeDialog() {
<ElInput v-model.trim="entity.title" /> <ElInput v-model.trim="entity.title" />
</ElFormItem> </ElFormItem>
<ElFormItem prop="categoryId" :label="$t('aiWorkflow.categoryId')"> <ElFormItem prop="categoryId" :label="$t('aiWorkflow.categoryId')">
<DictSelect <ElSelect v-model="entity.categoryId" clearable filterable>
v-model="entity.categoryId" <ElOption
dict-code="aiWorkFlowCategory" v-for="category in categoryOptions"
:key="category.id"
:label="category.categoryName"
:value="category.id"
/> />
</ElSelect>
</ElFormItem> </ElFormItem>
<ElFormItem <ElFormItem
prop="visibilityScope" prop="visibilityScope"

View File

@@ -76,17 +76,12 @@ function dedupeManagedDatasetOptions(options: ManagedDatasetOption[]) {
export async function loadManagedDatasetOptions(): Promise< export async function loadManagedDatasetOptions(): Promise<
ManagedDatasetOption[] ManagedDatasetOption[]
> { > {
const sourceRes = await api.get('/api/v1/datacenterSource/page', { const sourceRes = await api.get('/api/v1/workflow/designer/dataSources');
params: { const sources = sourceRes.data || [];
pageNumber: 1,
pageSize: 200,
},
});
const sources = sourceRes.data?.records || [];
const options: ManagedDatasetOption[] = []; const options: ManagedDatasetOption[] = [];
for (const source of sources) { for (const source of sources) {
try { try {
const catalogRes = await api.get('/api/v1/datacenterSource/catalogs', { const catalogRes = await api.get('/api/v1/workflow/designer/catalogs', {
params: { params: {
sourceId: source.id, sourceId: source.id,
}, },
@@ -94,7 +89,7 @@ export async function loadManagedDatasetOptions(): Promise<
const catalogs = catalogRes.data || []; const catalogs = catalogRes.data || [];
for (const catalog of catalogs) { for (const catalog of catalogs) {
const tableRes = await api.get( const tableRes = await api.get(
'/api/v1/datacenterDataset/managedTables', '/api/v1/workflow/designer/managedTables',
{ {
params: { params: {
sourceId: source.id, sourceId: source.id,
@@ -176,7 +171,7 @@ export async function loadManagedDatasetSchema(
fields: [], fields: [],
}; };
} }
const res = await api.get('/api/v1/datacenterDataset/schema', { const res = await api.get('/api/v1/workflow/designer/schema', {
params: datasetRef, params: datasetRef,
}); });
const data = res.data || {}; const data = res.data || {};
@@ -188,8 +183,8 @@ export async function loadManagedDatasetSchema(
})) }))
: []; : [];
return { return {
tableName: data.table?.tableName || datasetRef.tableName, tableName: data.tableName || datasetRef.tableName,
tableDesc: data.table?.tableDesc, tableDesc: data.tableDesc,
fields, fields,
}; };
} }

View File

@@ -28,18 +28,6 @@ interface ProviderOption {
value: string; value: string;
} }
interface ModelProvider {
key: string;
options?: ProviderOptionExtra;
title: string;
}
interface LlmOption {
extra: Map<string, string | undefined>;
label: string;
value: string;
}
interface SettingsEntity { interface SettingsEntity {
chatgpt_api_key: string; chatgpt_api_key: string;
chatgpt_chatPath: string; chatgpt_chatPath: string;
@@ -50,18 +38,6 @@ interface SettingsEntity {
} }
const providerOptions = ref<ProviderOption[]>(providerList as ProviderOption[]); const providerOptions = ref<ProviderOption[]>(providerList as ProviderOption[]);
const brands = ref<ModelProvider[]>([]);
const llmOptions = ref<LlmOption[]>([]);
// 获取品牌接口数据
function getBrands() {
api.get('/api/v1/modelProvider/list').then((res) => {
if (res.errorCode === 0) {
brands.value = (res.data ?? []) as ModelProvider[];
llmOptions.value = formatLlmList(brands.value);
}
});
}
function getOptions() { function getOptions() {
api api
.get( .get(
@@ -78,7 +54,6 @@ function getOptions() {
} }
onMounted(() => { onMounted(() => {
getOptions(); getOptions();
getBrands();
}); });
const entity = ref<SettingsEntity>({ const entity = ref<SettingsEntity>({
@@ -90,19 +65,6 @@ const entity = ref<SettingsEntity>({
chat_publish_base_url: '', chat_publish_base_url: '',
}); });
function formatLlmList(data: ModelProvider[]): LlmOption[] {
return data.map((item) => {
const extra = new Map([
['chatPath', item.options?.chatPath],
['llmEndpoint', item.options?.llmEndpoint],
]);
return {
label: item.title,
value: item.key,
extra,
};
});
}
function handleChangeModel(value: string) { function handleChangeModel(value: string) {
const extra = providerList.find((item) => item.value === value); const extra = providerList.find((item) => item.value === value);
entity.value.chatgpt_chatPath = extra?.options?.chatPath ?? ''; entity.value.chatgpt_chatPath = extra?.options?.chatPath ?? '';

View File

@@ -4,7 +4,7 @@ import pageSource from './index.vue?raw';
describe('管理端工作台智能体数据契约', () => { describe('管理端工作台智能体数据契约', () => {
it('使用 Agent 候选接口和名称字段', () => { it('使用 Agent 候选接口和名称字段', () => {
expect(pageSource).toContain("'/api/v1/agent/options'"); expect(pageSource).toContain("'/api/v1/dashboard/agentOptions'");
expect(pageSource).not.toContain('/api/v1/bot/list'); expect(pageSource).not.toContain('/api/v1/bot/list');
expect(pageSource).toContain('label: item.name'); expect(pageSource).toContain('label: item.name');
expect(pageSource).not.toContain('publishedOnly'); expect(pageSource).not.toContain('publishedOnly');

View File

@@ -332,7 +332,7 @@ async function loadAssistantOptions() {
try { try {
const agents = const agents =
await requestClient.get<Array<{ id?: number | string; name?: string }>>( await requestClient.get<Array<{ id?: number | string; name?: string }>>(
'/api/v1/agent/options', '/api/v1/dashboard/agentOptions',
); );
const nextOptions: AssistantOptionItem[] = [ const nextOptions: AssistantOptionItem[] = [
{ label: '全部智能体', value: '' }, { label: '全部智能体', value: '' },

View File

@@ -114,14 +114,14 @@ const ASSIGNEE_TYPE_OPTIONS: Array<{ label: string; value: AssigneeType }> = [
{ label: $t('approval.assignee.user'), value: 'USER' }, { label: $t('approval.assignee.user'), value: 'USER' },
{ label: $t('approval.assignee.dept'), value: 'DEPT' }, { label: $t('approval.assignee.dept'), value: 'DEPT' },
]; ];
const ENABLED_DATA_STATUS = 1;
const ASSIGNEE_VISIBLE_TAG_COUNT = 2; const ASSIGNEE_VISIBLE_TAG_COUNT = 2;
const saveForm = ref<FormInstance>(); const saveForm = ref<FormInstance>();
const dialogVisible = ref(false); const dialogVisible = ref(false);
const isAdd = ref(true); const isAdd = ref(true);
const btnLoading = ref(false); const btnLoading = ref(false);
const categoryLoaded = ref(false); const resourceScopeOptionsLoaded = ref(false);
let resourceScopeOptionsRequest: null | Promise<void> = null;
const roleLoaded = ref(false); const roleLoaded = ref(false);
const accountLoading = ref(false); const accountLoading = ref(false);
const deptTreeOptions = ref<any[]>([]); const deptTreeOptions = ref<any[]>([]);
@@ -178,7 +178,7 @@ watch(
return scope; return scope;
}); });
if (resourceType) { if (resourceType) {
void ensureCategoryOptions(); void ensureResourceScopeOptions();
} }
}, },
); );
@@ -212,8 +212,7 @@ async function openDialog(row: any = {}) {
formModel.value = buildDefaultForm(); formModel.value = buildDefaultForm();
dialogVisible.value = true; dialogVisible.value = true;
await Promise.all([ await Promise.all([
ensureCategoryOptions(), ensureResourceScopeOptions(),
ensureDeptOptions(),
ensureRoleOptions(), ensureRoleOptions(),
]); ]);
if (!row?.id) { if (!row?.id) {
@@ -276,40 +275,46 @@ async function openDialog(row: any = {}) {
} }
} }
async function ensureCategoryOptions() { async function ensureResourceScopeOptions() {
if (categoryLoaded.value) { if (resourceScopeOptionsLoaded.value) {
return; return;
} }
const [agentRes, workflowRes, knowledgeRes] = await Promise.all([ if (!resourceScopeOptionsRequest) {
api.get('/api/v1/agentCategory/list', { resourceScopeOptionsRequest = api
params: { sortKey: 'sortNo', sortType: 'asc' }, .get('/api/v1/approvalFlow/resourceScopeOptions')
}), .then((res) => {
api.get('/api/v1/workflowCategory/list', { const categories = res.data?.categories || {};
params: { sortKey: 'sortNo', sortType: 'asc' },
}),
api.get('/api/v1/documentCollectionCategory/list', {
params: { sortKey: 'sortNo', sortType: 'asc' },
}),
]);
categoryOptions.value = { categoryOptions.value = {
AGENT: normalizeCategoryOptions(agentRes.data, 'categoryName'), AGENT: normalizeCategoryOptions(categories.AGENT, 'categoryName'),
BOT: [], BOT: [],
KNOWLEDGE: normalizeCategoryOptions(knowledgeRes.data, 'categoryName'), KNOWLEDGE: normalizeCategoryOptions(
WORKFLOW: normalizeCategoryOptions(workflowRes.data, 'categoryName'), categories.KNOWLEDGE,
'categoryName',
),
WORKFLOW: normalizeCategoryOptions(
categories.WORKFLOW,
'categoryName',
),
}; };
categoryLoaded.value = true; deptTreeOptions.value = Array.isArray(res.data?.departments)
} ? res.data.departments
: [];
async function ensureDeptOptions() { resourceScopeOptionsLoaded.value = true;
const res = await api.get('/api/v1/sysDept/list', { })
params: { .catch(() => {
asTree: true, categoryOptions.value = {
sortKey: 'sortNo', AGENT: [],
sortType: 'asc', BOT: [],
status: ENABLED_DATA_STATUS, KNOWLEDGE: [],
}, WORKFLOW: [],
};
deptTreeOptions.value = [];
})
.finally(() => {
resourceScopeOptionsRequest = null;
}); });
deptTreeOptions.value = Array.isArray(res.data) ? res.data : []; }
await resourceScopeOptionsRequest;
} }
async function ensureRoleOptions() { async function ensureRoleOptions() {

View File

@@ -5,7 +5,15 @@ import { onMounted, ref, watch } from 'vue';
import { EasyFlowFormModal, EasyFlowInputPassword } from '@easyflow/common-ui'; import { EasyFlowFormModal, EasyFlowInputPassword } from '@easyflow/common-ui';
import { ElForm, ElFormItem, ElInput, ElMessage } from 'element-plus'; import {
ElForm,
ElFormItem,
ElInput,
ElMessage,
ElOption,
ElSelect,
ElTreeSelect,
} from 'element-plus';
import { getCredentialKeyApi } from '#/api'; import { getCredentialKeyApi } from '#/api';
import { api } from '#/api/request'; import { api } from '#/api/request';
@@ -26,6 +34,10 @@ const saveForm = ref<FormInstance>();
// variables // variables
const dialogVisible = ref(false); const dialogVisible = ref(false);
const isAdd = ref(true); const isAdd = ref(true);
const departmentOptions = ref<any[]>([]);
const roleOptions = ref<any[]>([]);
const positionOptions = ref<any[]>([]);
let formOptionsLoaded = false;
function createDefaultEntity() { function createDefaultEntity() {
return { return {
deptId: '', deptId: '',
@@ -110,7 +122,7 @@ const rules = ref({
], ],
}); });
// functions // functions
function openDialog(row: any) { async function openDialog(row: any) {
isAdd.value = !row?.id; isAdd.value = !row?.id;
entity.value = { entity.value = {
...createDefaultEntity(), ...createDefaultEntity(),
@@ -123,6 +135,23 @@ function openDialog(row: any) {
entity.value.positionIds = []; entity.value.positionIds = [];
} }
dialogVisible.value = true; dialogVisible.value = true;
if (!formOptionsLoaded) {
try {
const res = await api.get('/api/v1/sysAccount/formOptions');
departmentOptions.value = Array.isArray(res.data?.departments)
? res.data.departments
: [];
roleOptions.value = Array.isArray(res.data?.roles) ? res.data.roles : [];
positionOptions.value = Array.isArray(res.data?.positions)
? res.data.positions
: [];
formOptionsLoaded = true;
} catch {
departmentOptions.value = [];
roleOptions.value = [];
positionOptions.value = [];
}
}
} }
function save() { function save() {
saveForm.value?.validate(async (valid) => { saveForm.value?.validate(async (valid) => {
@@ -197,7 +226,14 @@ watch(
<UploadAvatar v-model="entity.avatar" /> <UploadAvatar v-model="entity.avatar" />
</ElFormItem> </ElFormItem>
<ElFormItem prop="deptId" :label="$t('sysAccount.deptId')"> <ElFormItem prop="deptId" :label="$t('sysAccount.deptId')">
<DictSelect v-model="entity.deptId" dict-code="sysDept" /> <ElTreeSelect
v-model="entity.deptId"
:data="departmentOptions"
:props="{ label: 'deptName', value: 'id', children: 'children' }"
check-strictly
clearable
filterable
/>
</ElFormItem> </ElFormItem>
<ElFormItem prop="loginName" :label="$t('sysAccount.loginName')"> <ElFormItem prop="loginName" :label="$t('sysAccount.loginName')">
<ElInput v-model.trim="entity.loginName" /> <ElInput v-model.trim="entity.loginName" />
@@ -244,14 +280,24 @@ watch(
:label="$t('sysAccount.roleIds')" :label="$t('sysAccount.roleIds')"
:required="isAdd" :required="isAdd"
> >
<DictSelect multiple v-model="entity.roleIds" dict-code="sysRole" /> <ElSelect v-model="entity.roleIds" multiple clearable filterable>
<ElOption
v-for="role in roleOptions"
:key="role.id"
:label="role.roleName"
:value="role.id"
/>
</ElSelect>
</ElFormItem> </ElFormItem>
<ElFormItem prop="positionIds" :label="$t('sysAccount.positionIds')"> <ElFormItem prop="positionIds" :label="$t('sysAccount.positionIds')">
<DictSelect <ElSelect v-model="entity.positionIds" multiple clearable filterable>
multiple <ElOption
v-model="entity.positionIds" v-for="position in positionOptions"
dict-code="sysPosition" :key="position.id"
:label="position.positionName"
:value="position.id"
/> />
</ElSelect>
</ElFormItem> </ElFormItem>
</ElForm> </ElForm>
</EasyFlowFormModal> </EasyFlowFormModal>

View File

@@ -5,7 +5,13 @@ import { ref } from 'vue';
import { EasyFlowFormModal } from '@easyflow/common-ui'; import { EasyFlowFormModal } from '@easyflow/common-ui';
import { ElForm, ElFormItem, ElInput, ElMessage } from 'element-plus'; import {
ElForm,
ElFormItem,
ElInput,
ElMessage,
ElTreeSelect,
} from 'element-plus';
import { api } from '#/api/request'; import { api } from '#/api/request';
import DictSelect from '#/components/dict/DictSelect.vue'; import DictSelect from '#/components/dict/DictSelect.vue';
@@ -22,6 +28,8 @@ const dialogVisible = ref(false);
const isAdd = ref(true); const isAdd = ref(true);
const entity = ref<any>(buildDefaultEntity()); const entity = ref<any>(buildDefaultEntity());
const btnLoading = ref(false); const btnLoading = ref(false);
const parentDepartmentOptions = ref<any[]>([]);
let parentDepartmentOptionsLoaded = false;
const rules = ref({ const rules = ref({
parentId: [ parentId: [
{ required: true, message: $t('message.required'), trigger: 'blur' }, { required: true, message: $t('message.required'), trigger: 'blur' },
@@ -45,13 +53,31 @@ function buildDefaultEntity() {
status: ENABLED_STATUS, status: ENABLED_STATUS,
}; };
} }
function openDialog(row: any = {}) { async function openDialog(row: any = {}) {
isAdd.value = !row.id; isAdd.value = !row.id;
entity.value = { entity.value = {
...buildDefaultEntity(), ...buildDefaultEntity(),
...row, ...row,
}; };
dialogVisible.value = true; dialogVisible.value = true;
if (!parentDepartmentOptionsLoaded) {
try {
const res = await api.get('/api/v1/sysDept/list', {
params: {
asTree: true,
sortKey: 'sortNo',
sortType: 'asc',
},
});
parentDepartmentOptions.value = [
{ id: 0, deptName: $t('sysDept.root'), children: [] },
...(Array.isArray(res.data) ? res.data : []),
];
parentDepartmentOptionsLoaded = true;
} catch {
parentDepartmentOptions.value = [];
}
}
} }
async function save() { async function save() {
if (btnLoading.value) { if (btnLoading.value) {
@@ -104,11 +130,14 @@ function closeDialog() {
class="easyflow-modal-form easyflow-modal-form--compact" class="easyflow-modal-form easyflow-modal-form--compact"
> >
<ElFormItem prop="parentId" :label="$t('sysDept.parentId')"> <ElFormItem prop="parentId" :label="$t('sysDept.parentId')">
<DictSelect <ElTreeSelect
:disabled="entity.deptCode === 'root_dept'" :disabled="entity.deptCode === 'root_dept'"
:extra-options="[{ label: $t('sysDept.root'), value: 0 }]"
v-model="entity.parentId" v-model="entity.parentId"
dict-code="sysDept" :data="parentDepartmentOptions"
:props="{ label: 'deptName', value: 'id', children: 'children' }"
check-strictly
clearable
filterable
/> />
</ElFormItem> </ElFormItem>
<ElFormItem prop="deptName" :label="$t('sysDept.deptName')"> <ElFormItem prop="deptName" :label="$t('sysDept.deptName')">

View File

@@ -78,7 +78,14 @@ vi.mock('element-plus', () => ({
}, },
}), }),
ElInput: defineComponent({ name: 'ElInput', setup: () => () => h('input') }), ElInput: defineComponent({ name: 'ElInput', setup: () => () => h('input') }),
ElMessage: { success: vi.fn() }, ElMessage: { error: vi.fn(), success: vi.fn() },
ElOption: defineComponent({ name: 'ElOption', setup: () => () => h('div') }),
ElSelect: defineComponent({
name: 'ElSelect',
setup(_, { slots }) {
return () => h('div', slots.default?.());
},
}),
})); }));
describe('sys job modal', () => { describe('sys job modal', () => {
@@ -112,7 +119,8 @@ describe('sys job modal', () => {
await flushPromises(); await flushPromises();
expect(apiMocks.get).toHaveBeenCalledWith( expect(apiMocks.get).toHaveBeenCalledWith(
'/api/v1/workflow/getRunningParameters?id=101', '/api/v1/sysJob/workflowRunningParameters',
{ params: { id: '101' } },
); );
expect(wrapper.get('form').attributes('data-loading')).toBe('false'); expect(wrapper.get('form').attributes('data-loading')).toBe('false');
expect(wrapper.text()).toContain('所选工作流已不可用,请重新选择'); expect(wrapper.text()).toContain('所选工作流已不可用,请重新选择');

View File

@@ -5,7 +5,15 @@ import { computed, onMounted, ref } from 'vue';
import { EasyFlowFormModal } from '@easyflow/common-ui'; import { EasyFlowFormModal } from '@easyflow/common-ui';
import { ElAlert, ElForm, ElFormItem, ElInput, ElMessage } from 'element-plus'; import {
ElAlert,
ElForm,
ElFormItem,
ElInput,
ElMessage,
ElOption,
ElSelect,
} from 'element-plus';
import { api } from '#/api/request'; import { api } from '#/api/request';
import CronPicker from '#/components/cron/CronPicker.vue'; import CronPicker from '#/components/cron/CronPicker.vue';
@@ -39,6 +47,8 @@ const initEntity = {
}; };
const entity = ref<any>(initEntity); const entity = ref<any>(initEntity);
const btnLoading = ref(false); const btnLoading = ref(false);
const workflowOptions = ref<any[]>([]);
let workflowOptionsLoaded = false;
// 基础验证规则 // 基础验证规则
const baseRules = ref({ const baseRules = ref({
jobName: [ jobName: [
@@ -85,8 +95,22 @@ function openDialog(row: any) {
} else { } else {
entity.value = { ...initEntity }; entity.value = { ...initEntity };
} }
void ensureWorkflowOptions();
dialogVisible.value = true; dialogVisible.value = true;
} }
async function ensureWorkflowOptions() {
if (workflowOptionsLoaded) {
return;
}
try {
const res = await api.get('/api/v1/sysJob/workflowOptions');
workflowOptions.value = Array.isArray(res.data) ? res.data : [];
workflowOptionsLoaded = true;
} catch {
workflowOptions.value = [];
ElMessage.error('工作流列表加载失败');
}
}
function save() { function save() {
if (btnLoading.value || workflowParamsSubmissionBlocked.value) { if (btnLoading.value || workflowParamsSubmissionBlocked.value) {
return; return;
@@ -146,7 +170,9 @@ async function getWorkflowParams(v: any) {
workflowParams.value = []; workflowParams.value = [];
workflowParamsLoadError.value = ''; workflowParamsLoadError.value = '';
try { try {
const res = await api.get(`/api/v1/workflow/getRunningParameters?id=${v}`); const res = await api.get('/api/v1/sysJob/workflowRunningParameters', {
params: { id: v },
});
if (requestId !== workflowParamsRequestId) { if (requestId !== workflowParamsRequestId) {
return; return;
} }
@@ -209,11 +235,19 @@ const str = '"param"';
{ required: true, message: $t('message.required'), trigger: 'blur' }, { required: true, message: $t('message.required'), trigger: 'blur' },
]" ]"
> >
<DictSelect <ElSelect
v-model="entity.jobParams.workflowId" v-model="entity.jobParams.workflowId"
dict-code="aiWorkFlow" clearable
filterable
@change="workflowChange" @change="workflowChange"
>
<ElOption
v-for="workflow in workflowOptions"
:key="workflow.id"
:label="workflow.title"
:value="workflow.id"
/> />
</ElSelect>
</ElFormItem> </ElFormItem>
<ElAlert <ElAlert
v-if="workflowParamsLoadError" v-if="workflowParamsLoadError"

View File

@@ -12,6 +12,7 @@ import {
ElMessage, ElMessage,
ElOption, ElOption,
ElSelect, ElSelect,
ElTreeSelect,
} from 'element-plus'; } from 'element-plus';
import { api } from '#/api/request'; import { api } from '#/api/request';
@@ -44,6 +45,8 @@ const entity = ref<any>({
remark: '', remark: '',
}); });
const btnLoading = ref(false); const btnLoading = ref(false);
const parentMenuOptions = ref<any[]>([]);
let parentMenuOptionsLoaded = false;
const rules = ref({ const rules = ref({
parentId: [ parentId: [
{ required: true, message: $t('message.required'), trigger: 'blur' }, { required: true, message: $t('message.required'), trigger: 'blur' },
@@ -62,12 +65,26 @@ const rules = ref({
], ],
}); });
// functions // functions
function openDialog(row: any) { async function openDialog(row: any) {
if (row.id) { if (row.id) {
isAdd.value = false; isAdd.value = false;
} }
entity.value = row; entity.value = row;
dialogVisible.value = true; dialogVisible.value = true;
if (!parentMenuOptionsLoaded) {
try {
const res = await api.get('/api/v1/sysMenu/list', {
params: { asTree: true },
});
parentMenuOptions.value = [
{ id: 0, menuTitle: $t('sysMenu.root'), children: [] },
...(Array.isArray(res.data) ? res.data : []),
];
parentMenuOptionsLoaded = true;
} catch {
parentMenuOptions.value = [];
}
}
} }
function save() { function save() {
saveForm.value?.validate((valid) => { saveForm.value?.validate((valid) => {
@@ -121,10 +138,13 @@ function closeDialog() {
class="easyflow-modal-form easyflow-modal-form--compact" class="easyflow-modal-form easyflow-modal-form--compact"
> >
<ElFormItem prop="parentId" :label="$t('sysMenu.parentId')"> <ElFormItem prop="parentId" :label="$t('sysMenu.parentId')">
<DictSelect <ElTreeSelect
:extra-options="[{ label: $t('sysMenu.root'), value: 0 }]"
v-model="entity.parentId" v-model="entity.parentId"
dict-code="sysMenu" :data="parentMenuOptions"
:props="{ label: 'menuTitle', value: 'id', children: 'children' }"
check-strictly
clearable
filterable
/> />
</ElFormItem> </ElFormItem>
<ElFormItem prop="menuType" :label="$t('sysMenu.menuType')"> <ElFormItem prop="menuType" :label="$t('sysMenu.menuType')">

View File

@@ -19,7 +19,6 @@ defineExpose({
type ResourceType = type ResourceType =
| 'AGENT' | 'AGENT'
| 'BOT'
| 'KNOWLEDGE' | 'KNOWLEDGE'
| 'PLUGIN' | 'PLUGIN'
| 'RESOURCE' | 'RESOURCE'
@@ -47,7 +46,6 @@ const RESOURCE_SCOPE_GROUPS: Array<{
resourceType: ResourceType; resourceType: ResourceType;
}> = [ }> = [
{ resourceType: 'AGENT', label: $t('menus.ai.agents') }, { resourceType: 'AGENT', label: $t('menus.ai.agents') },
{ resourceType: 'BOT', label: $t('bot.chatAssistant') },
{ resourceType: 'PLUGIN', label: $t('menus.ai.plugin') }, { resourceType: 'PLUGIN', label: $t('menus.ai.plugin') },
{ resourceType: 'WORKFLOW', label: $t('menus.ai.workflow') }, { resourceType: 'WORKFLOW', label: $t('menus.ai.workflow') },
{ resourceType: 'KNOWLEDGE', label: $t('menus.ai.documentCollection') }, { resourceType: 'KNOWLEDGE', label: $t('menus.ai.documentCollection') },
@@ -58,11 +56,11 @@ const saveForm = ref<FormInstance>();
const dialogVisible = ref(false); const dialogVisible = ref(false);
const isAdd = ref(true); const isAdd = ref(true);
const entity = ref<any>(buildDefaultEntity()); const entity = ref<any>(buildDefaultEntity());
const categoryScopeLoaded = ref(false); const formOptionsLoaded = ref(false);
const menuOptions = ref<any[]>([]);
const categoryScopeEditable = ref(false); const categoryScopeEditable = ref(false);
const categoryOptions = ref<Record<ResourceType, CategoryOption[]>>({ const categoryOptions = ref<Record<ResourceType, CategoryOption[]>>({
AGENT: [], AGENT: [],
BOT: [],
KNOWLEDGE: [], KNOWLEDGE: [],
PLUGIN: [], PLUGIN: [],
RESOURCE: [], RESOURCE: [],
@@ -135,7 +133,7 @@ function openDialog(row: any = {}) {
getMenuIds(row.id); getMenuIds(row.id);
} }
getCategoryScopeDetail(row.id); getCategoryScopeDetail(row.id);
void ensureCategoryOptions(); void ensureFormOptions();
dialogVisible.value = true; dialogVisible.value = true;
} }
@@ -189,40 +187,32 @@ function getMenuIds(roleId: any) {
}); });
} }
async function ensureCategoryOptions() { async function ensureFormOptions() {
if (categoryScopeLoaded.value) { if (formOptionsLoaded.value) {
return; return;
} }
const requests = [ try {
api.get('/api/v1/agentCategory/list', { const res = await api.get('/api/v1/sysRole/formOptions');
params: { sortKey: 'sortNo', sortType: 'asc' }, const categories = res.data?.categories || {};
}), menuOptions.value = Array.isArray(res.data?.menus) ? res.data.menus : [];
api.get('/api/v1/botCategory/list', {
params: { sortKey: 'sortNo', sortType: 'asc' },
}),
api.get('/api/v1/pluginCategory/list'),
api.get('/api/v1/workflowCategory/list', {
params: { sortKey: 'sortNo', sortType: 'asc' },
}),
api.get('/api/v1/documentCollectionCategory/list', {
params: { sortKey: 'sortNo', sortType: 'asc' },
}),
api.get('/api/v1/resourceCategory/list', {
params: { sortKey: 'sortNo', sortType: 'asc' },
}),
];
const [agentRes, botRes, pluginRes, workflowRes, knowledgeRes, resourceRes] =
await Promise.all(requests);
categoryOptions.value = { categoryOptions.value = {
AGENT: normalizeCategoryOptions(agentRes.data, 'categoryName'), AGENT: normalizeCategoryOptions(categories.AGENT, 'categoryName'),
BOT: normalizeCategoryOptions(botRes.data, 'categoryName'), KNOWLEDGE: normalizeCategoryOptions(categories.KNOWLEDGE, 'categoryName'),
KNOWLEDGE: normalizeCategoryOptions(knowledgeRes.data, 'categoryName'), PLUGIN: normalizeCategoryOptions(categories.PLUGIN, 'categoryName'),
PLUGIN: normalizeCategoryOptions(pluginRes.data, 'name'), RESOURCE: normalizeCategoryOptions(categories.RESOURCE, 'categoryName'),
RESOURCE: normalizeCategoryOptions(resourceRes.data, 'categoryName'), WORKFLOW: normalizeCategoryOptions(categories.WORKFLOW, 'categoryName'),
WORKFLOW: normalizeCategoryOptions(workflowRes.data, 'categoryName'),
}; };
categoryScopeLoaded.value = true; formOptionsLoaded.value = true;
} catch {
menuOptions.value = [];
categoryOptions.value = {
AGENT: [],
KNOWLEDGE: [],
PLUGIN: [],
RESOURCE: [],
WORKFLOW: [],
};
}
} }
function normalizeCategoryOptions(data: any[] = [], labelKey: string) { function normalizeCategoryOptions(data: any[] = [], labelKey: string) {
@@ -296,7 +286,13 @@ function getCategoryScopeDetail(roleId: number | string) {
categoryScopeEditable.value = !!res.data?.editable; categoryScopeEditable.value = !!res.data?.editable;
categoryScopeDetail.value = { categoryScopeDetail.value = {
roleId, roleId,
scopes: (res.data?.scopes || buildDefaultScopes()).map((item: any) => ({ scopes: (res.data?.scopes || buildDefaultScopes())
.filter((item: any) =>
RESOURCE_SCOPE_GROUPS.some(
(group) => group.resourceType === item.resourceType,
),
)
.map((item: any) => ({
categoryIds: item.categoryIds || [], categoryIds: item.categoryIds || [],
resourceType: item.resourceType, resourceType: item.resourceType,
scopeMode: item.scopeMode || 'CUSTOM', scopeMode: item.scopeMode || 'CUSTOM',
@@ -307,7 +303,7 @@ function getCategoryScopeDetail(roleId: number | string) {
} }
async function saveCategoryScope(roleId: number | string) { async function saveCategoryScope(roleId: number | string) {
await ensureCategoryOptions(); await ensureFormOptions();
const scopes = buildScopeItemsFromTree(); const scopes = buildScopeItemsFromTree();
categoryScopeDetail.value = { categoryScopeDetail.value = {
roleId, roleId,
@@ -353,7 +349,7 @@ async function saveCategoryScope(roleId: number | string) {
</ElFormItem> </ElFormItem>
<ElFormItem :label="$t('sysRole.menuPermission')"> <ElFormItem :label="$t('sysRole.menuPermission')">
<Tree <Tree
data-url="/api/v1/sysMenu/list?asTree=true" :data="menuOptions"
v-model="entity.menuIds" v-model="entity.menuIds"
:default-props="{ :default-props="{
label: 'menuTitle', label: 'menuTitle',