package com.agent.management.external; import com.agent.management.config.ExternalApiAuthFilter; import com.agent.management.config.ExternalApiProperties; import com.agent.management.engine.AgentIgnoreFilter; import com.agent.management.engine.WorkflowEngine; import com.agent.management.engine.WorkflowRunDirManager; import com.agent.management.engine.WorkflowRunEvent; import com.agent.management.external.dto.ApiError; import com.agent.management.external.dto.ExternalDtos.CreateRunRequest; import com.agent.management.external.dto.ExternalDtos.CreateRunResponse; import com.agent.management.external.dto.ExternalDtos.FieldDef; import com.agent.management.external.dto.ExternalDtos.Links; import com.agent.management.external.dto.ExternalDtos.LogEntry; import com.agent.management.external.dto.ExternalDtos.NodeLogsResponse; import com.agent.management.external.dto.ExternalDtos.NodeResult; import com.agent.management.external.dto.ExternalDtos.RunResultResponse; import com.agent.management.external.dto.ExternalDtos.StartRunResponse; import com.agent.management.external.dto.ExternalDtos.WorkflowListResponse; import com.agent.management.external.dto.ExternalDtos.WorkflowSummary; import com.agent.management.external.dto.ExternalDtos.WorkspaceInfo; import com.agent.management.model.entity.Workflow; import com.agent.management.model.entity.WorkflowRun; import com.agent.management.model.entity.WorkflowRunNode; import com.agent.management.repository.WorkflowRepository; import com.agent.management.repository.WorkflowRunNodeRepository; import com.agent.management.repository.WorkflowRunRepository; import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.core.io.Resource; import org.springframework.core.io.UrlResource; import org.springframework.http.HttpHeaders; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; import org.springframework.web.multipart.MultipartFile; import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; import java.io.IOException; import java.net.URLEncoder; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.time.Instant; import java.time.LocalDateTime; import java.time.ZoneOffset; import java.util.*; import java.util.stream.Collectors; import java.util.zip.ZipEntry; import java.util.zip.ZipOutputStream; /** * 外部 API(/api/v1/**)入口:第三方系统通过此 Controller 调用平台工作流。 * *

URL 路径使用 kebab-case 唯一标识 {@code workflowName}(与 Skill 一致)。 * 旧调用方使用数字 id 的 URL 仍可通过 by-id 兜底逻辑访问(用户手动改名为非数字前都自动兼容)。 * * 流程: * - POST /workflows/{workflowName}/runs 创建运行 * - POST /workflows/{workflowName}/runs/{runId}/start 启动执行 * - GET /workflows/{workflowName}/runs/{runId}/stream 订阅 SSE 流 * - GET /workflows/{workflowName}/runs/{runId} 查询运行结果 * - GET /workflows/{workflowName}/runs/{runId}/workspace 下载工作空间 zip * - GET /workflows/{workflowName}/runs/{runId}/nodes/{nodeId}/logs 查询节点日志 * - GET /workflows 列出当前 API Key 可调用的工作流 */ @Slf4j @RestController @RequestMapping("/api/v1") @RequiredArgsConstructor public class ExternalApiController { private final ExternalApiProperties properties; private final ExternalRunRegistry runRegistry; private final SseEventBus eventBus; private final WorkflowEngine workflowEngine; private final WorkflowRepository workflowRepository; private final WorkflowRunRepository workflowRunRepository; private final WorkflowRunNodeRepository workflowRunNodeRepository; private final WorkflowRunDirManager runDirManager; private final AgentIgnoreFilter agentIgnoreFilter; private static final ObjectMapper MAPPER = new ObjectMapper().registerModule(new JavaTimeModule()); private static final int DEFAULT_TTL_HOURS = 720; private static final long SSE_TIMEOUT_MS = 1_800_000L; // ============================================================ // POST /workflows/{workflowName}/runs — 创建运行 // ============================================================ @PostMapping(value = "/workflows/{workflowName}/runs", consumes = MediaType.APPLICATION_JSON_VALUE) public ResponseEntity createRunJson(@PathVariable String workflowName, @RequestAttribute(ExternalApiAuthFilter.ATTR_KEY_ENTRY) ExternalApiProperties.KeyEntry keyEntry, @RequestBody(required = false) CreateRunRequest req) { return doCreateRun(workflowName, req, null, null); } @PostMapping(value = "/workflows/{workflowName}/runs", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) public ResponseEntity createRunMultipart(@PathVariable String workflowName, @RequestAttribute(ExternalApiAuthFilter.ATTR_KEY_ENTRY) ExternalApiProperties.KeyEntry keyEntry, @RequestParam(value = "payload", required = false) String payloadJson, @RequestParam(value = "files", required = false) MultipartFile[] files, @RequestHeader(value = "X-Relative-Path", required = false) List relativePaths) { CreateRunRequest req = parsePayload(payloadJson); return doCreateRun(workflowName, req, files, relativePaths); } private ResponseEntity doCreateRun(String workflowName, CreateRunRequest req, MultipartFile[] files, List relativePaths) { // 1. 工作流解析 Workflow wf = resolveWorkflow(workflowName); if (wf == null) { return error(404, "WORKFLOW_NOT_FOUND", "工作流不存在: " + workflowName); } if (wf.getGraphData() == null || wf.getGraphData().isBlank() || wf.getGraphData().equals("{\"nodes\":[],\"edges\":[]}")) { return error(422, "GRAPH_INVALID", "工作流图为空,无法执行"); } // 2. 准备 inputs Map inputs = new HashMap<>(); if (req != null && req.getVariables() != null) { inputs.putAll(req.getVariables()); } // 3. 生成 runId + 创建工作目录 + 写入上传文件 String runId; try { runId = generateRunId(); Path runDir = runDirManager.createRunDir(wf.getId(), wf.getName(), runId); List uploadedFiles = new ArrayList<>(); if (files != null) { for (int i = 0; i < files.length; i++) { MultipartFile file = files[i]; if (file == null || file.isEmpty()) continue; String rel = (relativePaths != null && i < relativePaths.size() && !relativePaths.get(i).isBlank()) ? relativePaths.get(i) : file.getOriginalFilename(); if (rel == null || rel.isBlank()) continue; Path dest = runDir.resolve(rel).normalize(); if (!dest.startsWith(runDir)) { return error(400, "VALIDATION_FAILED", "非法上传路径: " + rel); } Files.createDirectories(dest.getParent()); file.transferTo(dest.toFile()); uploadedFiles.add(runDir.relativize(dest).toString().replace('\\', '/')); } } if (!uploadedFiles.isEmpty()) { inputs.put("uploadedFiles", uploadedFiles); } } catch (IllegalArgumentException e) { return error(400, "VALIDATION_FAILED", e.getMessage()); } catch (IOException e) { log.error("[ExternalApi] 创建运行失败 workflowName={}", workflowName, e); return error(500, "INTERNAL_ERROR", "工作目录创建失败"); } // 4. 注册 CREATED 状态 int ttlHours = (req != null && req.getTtlHours() != null && req.getTtlHours() > 0) ? req.getTtlHours() : DEFAULT_TTL_HOURS; runRegistry.register(runId, wf.getId(), wf.getName(), inputs, ttlHours); eventBus.register(runId); // 5. async=false:立即启动并直接进入 SSE 流 boolean async = req == null || req.getAsync() == null || req.getAsync(); if (!async) { return startRun(wf.getName(), wf.getId(), runId, inputs); } // 6. async=true:返回 runId + links(URL 使用 workflowName) CreateRunResponse body = new CreateRunResponse(); body.setRunId(runId); body.setWorkflowId(wf.getId()); body.setWorkflowName(wf.getName()); body.setDisplayName(wf.getDisplayName()); body.setStatus("CREATED"); body.setCreatedAt(Instant.now()); Links links = new Links(); String base = "/api/v1/workflows/" + wf.getName() + "/runs/" + runId; links.setStart(base + "/start"); links.setStream(base + "/stream"); links.setResult(base); links.setWorkspace(base + "/workspace"); body.setLinks(links); return ResponseEntity.ok(ApiError.ok(body)); } // ============================================================ // POST /workflows/{workflowName}/runs/{runId}/start — 启动运行 // ============================================================ @PostMapping("/workflows/{workflowName}/runs/{runId}/start") public ResponseEntity startRun(@PathVariable String workflowName, @PathVariable String runId) { ExternalRunRegistry.CreatedRun created = runRegistry.consume(runId); if (created == null) { return error(404, "RUN_NOT_FOUND", "运行不存在或已启动: " + runId); } // 校验 pathVariable 与 created 一致(兼容数字字符串) if (!matchesWorkflow(created, workflowName)) { return error(400, "VALIDATION_FAILED", "runId 与 workflowName 不匹配"); } return startRun(created.workflowName(), created.workflowId(), runId, created.inputs()); } private ResponseEntity startRun(String workflowName, Long workflowId, String runId, Map inputs) { workflowEngine.executeForExternal(workflowId, runId, inputs); StartRunResponse body = new StartRunResponse(); body.setRunId(runId); body.setStatus("RUNNING"); body.setStreamUrl("/api/v1/workflows/" + workflowName + "/runs/" + runId + "/stream"); return ResponseEntity.ok(ApiError.ok(body)); } // ============================================================ // GET /workflows/{workflowName}/runs/{runId}/stream — SSE 流 // ============================================================ @GetMapping(value = "/workflows/{workflowName}/runs/{runId}/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE) public ResponseEntity streamRun(@PathVariable String workflowName, @PathVariable String runId, @RequestHeader(value = "Last-Event-ID", required = false) Long lastEventId) { if (!eventBus.exists(runId)) { WorkflowRun record = findRunRecord(runId); if (record == null) { return ResponseEntity.notFound().build(); } eventBus.register(runId); eventBus.publish(runId, record.getStatus().equals("FAILED") ? "workflow_error" : "workflow_complete", WorkflowRunEvent.workflowComplete(runId, parseOutputs(record))); } SseEmitter emitter = eventBus.subscribe(runId, lastEventId); if (emitter == null) { return ResponseEntity.notFound().build(); } return ResponseEntity.ok() .header("X-Accel-Buffering", "no") .header("Cache-Control", "no-cache") .body(emitter); } // ============================================================ // GET /workflows/{workflowName}/runs/{runId} — 查询运行结果 // ============================================================ @GetMapping("/workflows/{workflowName}/runs/{runId}") public ResponseEntity getRunResult(@PathVariable String workflowName, @PathVariable String runId) { Workflow wf = resolveWorkflow(workflowName); if (wf == null) { return error(404, "WORKFLOW_NOT_FOUND", "工作流不存在: " + workflowName); } RunResultResponse body = new RunResultResponse(); body.setRunId(runId); body.setWorkflowId(wf.getId()); body.setWorkflowName(wf.getName()); body.setDisplayName(wf.getDisplayName()); SseEventBus.RunStatus live = eventBus.getStatus(runId); if (live != null) { body.setStatus(live.status()); } WorkflowRun record = findRunRecord(runId); if (record == null) { ExternalRunRegistry.CreatedRun created = runRegistry.peek(runId); if (created != null) { body.setStatus("CREATED"); return ResponseEntity.ok(ApiError.ok(body)); } return error(404, "RUN_NOT_FOUND", "运行不存在: " + runId); } body.setStatus(record.getStatus()); if (record.getStartedAt() != null) { body.setStartedAt(record.getStartedAt().toInstant(ZoneOffset.UTC)); } if (record.getCompletedAt() != null) { body.setCompletedAt(record.getCompletedAt().toInstant(ZoneOffset.UTC)); } body.setError(record.getError()); body.setOutputs(parseOutputs(record)); List nodes = workflowRunNodeRepository.findByRunIdOrderBySortOrderAsc(record.getId()); List nodeDtos = nodes.stream().map(n -> { NodeResult dto = new NodeResult(); dto.setNodeId(n.getNodeId()); dto.setNodeType(n.getNodeType()); dto.setLabel(n.getLabel()); dto.setStatus(n.getStatus()); dto.setOutput(parseJsonToMap(n.getOutput())); dto.setError(n.getError()); dto.setLogsCount(countLogs(n.getLogs())); dto.setLogsUrl("/api/v1/workflows/" + wf.getName() + "/runs/" + runId + "/nodes/" + n.getNodeId() + "/logs"); return dto; }).collect(Collectors.toList()); body.setNodes(nodeDtos); Path runDir = runDirManager.getRunDir(wf.getId(), wf.getName(), runId); if (runDir != null) { try { final int[] fileCount = {0}; final long[] totalBytes = {0}; Files.walk(runDir) .filter(p -> !Files.isDirectory(p) && shouldIncludeInZip(runDir, p)) .forEach(p -> { fileCount[0]++; try { totalBytes[0] += Files.size(p); } catch (IOException ignore) {} }); WorkspaceInfo ws = new WorkspaceInfo(); ws.setFileCount(fileCount[0]); ws.setTotalBytes(totalBytes[0]); ws.setDownloadUrl("/api/v1/workflows/" + wf.getName() + "/runs/" + runId + "/workspace"); body.setWorkspace(ws); } catch (IOException e) { log.warn("[ExternalApi] 统计工作目录失败 runId={}: {}", runId, e.getMessage()); } } return ResponseEntity.ok(ApiError.ok(body)); } // ============================================================ // GET /workflows/{workflowName}/runs/{runId}/workspace // ============================================================ @GetMapping("/workflows/{workflowName}/runs/{runId}/workspace") public ResponseEntity downloadWorkspace(@PathVariable String workflowName, @PathVariable String runId) throws IOException { Workflow wf = resolveWorkflow(workflowName); if (wf == null) { return ResponseEntity.notFound().build(); } Path runDir = runDirManager.getRunDir(wf.getId(), wf.getName(), runId); if (runDir == null) { return ResponseEntity.notFound().build(); } Path zipPath = Files.createTempFile("external-run-" + runId, ".zip"); try (ZipOutputStream zos = new ZipOutputStream(Files.newOutputStream(zipPath))) { Files.walk(runDir) .filter(p -> !Files.isDirectory(p) && shouldIncludeInZip(runDir, p)) .forEach(p -> { ZipEntry entry = new ZipEntry(runDir.relativize(p).toString().replace('\\', '/')); try { zos.putNextEntry(entry); Files.copy(p, zos); zos.closeEntry(); } catch (IOException e) { log.warn("[ExternalApi] zip 条目写入失败: {}", p, e); } }); } Resource resource = new UrlResource(zipPath.toUri()); String filename = URLEncoder.encode("workflow-" + wf.getName() + "-run-" + runId + ".zip", StandardCharsets.UTF_8); return ResponseEntity.ok() .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename*=UTF-8''" + filename) .contentType(MediaType.APPLICATION_OCTET_STREAM) .body(resource); } // ============================================================ // GET /workflows/{workflowName}/runs/{runId}/nodes/{nodeId}/logs // ============================================================ @GetMapping("/workflows/{workflowName}/runs/{runId}/nodes/{nodeId}/logs") public ResponseEntity getNodeLogs(@PathVariable String workflowName, @PathVariable String runId, @PathVariable String nodeId) { WorkflowRun record = findRunRecord(runId); if (record == null) { return error(404, "RUN_NOT_FOUND", "运行不存在: " + runId); } List nodes = workflowRunNodeRepository.findByRunIdOrderBySortOrderAsc(record.getId()); WorkflowRunNode target = nodes.stream() .filter(n -> nodeId.equals(n.getNodeId())) .findFirst().orElse(null); if (target == null) { return error(404, "NODE_NOT_FOUND", "节点不存在: " + nodeId); } List logs = new ArrayList<>(); if (target.getLogs() != null && !target.getLogs().isBlank()) { try { List raw = MAPPER.readValue(target.getLogs(), new TypeReference>() {}); for (JsonNode n : raw) { LogEntry e = new LogEntry(); e.setType(n.path("type").asText("")); e.setMessage(n.path("message").asText("")); e.setDetail(n.path("detail").isNull() ? null : n.path("detail").asText("")); String ts = n.path("timestamp").asText(""); if (!ts.isEmpty()) { try { e.setTimestamp(Instant.parse(ts)); } catch (Exception ignore) {} } logs.add(e); } } catch (Exception e) { log.warn("[ExternalApi] 解析节点日志失败 nodeId={}: {}", nodeId, e.getMessage()); } } NodeLogsResponse body = new NodeLogsResponse(); body.setRunId(runId); body.setNodeId(nodeId); body.setLogs(logs); return ResponseEntity.ok(ApiError.ok(body)); } // ============================================================ // GET /workflows — 列出可调用工作流 // ============================================================ @GetMapping("/workflows") public ResponseEntity listWorkflows(@RequestAttribute(ExternalApiAuthFilter.ATTR_KEY_ENTRY) ExternalApiProperties.KeyEntry keyEntry) { List all = workflowRepository.findAllByOrderByUpdatedAtDesc(); List accessible = all.stream() .filter(w -> properties.canAccess(keyEntry, w.getName(), w.getId())) .filter(w -> w.getGraphData() != null && !w.getGraphData().isBlank()) .collect(Collectors.toList()); List items = new ArrayList<>(); for (Workflow w : accessible) { WorkflowSummary s = new WorkflowSummary(); s.setId(w.getId()); s.setName(w.getName()); s.setDisplayName(w.getDisplayName()); s.setDescription(w.getDescription()); try { JsonNode root = MAPPER.readTree(w.getGraphData()); s.setInputs(extractFields(root, "userInput")); s.setOutputs(extractFields(root, "output")); } catch (Exception e) { s.setInputs(List.of()); s.setOutputs(List.of()); } items.add(s); } WorkflowListResponse body = new WorkflowListResponse(); body.setTotal(items.size()); body.setItems(items); return ResponseEntity.ok(ApiError.ok(body)); } // ============================================================ // 工具方法 // ============================================================ /** * 解析工作流:优先 findByName,回退 findById(兼容数字 id URL)。 */ private Workflow resolveWorkflow(String nameOrId) { if (nameOrId == null || nameOrId.isEmpty()) return null; return workflowRepository.findByName(nameOrId) .orElseGet(() -> { if (nameOrId.matches("\\d+")) { return workflowRepository.findById(Long.valueOf(nameOrId)).orElse(null); } return null; }); } /** 判断 created run 是否与 pathVariable workflowName 匹配(name 或数字 id 兼容) */ private boolean matchesWorkflow(ExternalRunRegistry.CreatedRun created, String workflowName) { if (created == null || workflowName == null) return false; if (workflowName.equals(created.workflowName())) return true; if (workflowName.matches("\\d+") && Long.valueOf(workflowName).equals(created.workflowId())) return true; return false; } private ResponseEntity error(int httpStatus, String code, String message) { return ResponseEntity.status(httpStatus).body(ApiError.error(code, message)); } private String generateRunId() { return UUID.randomUUID().toString().substring(0, 8); } private CreateRunRequest parsePayload(String payloadJson) { if (payloadJson == null || payloadJson.isBlank()) return new CreateRunRequest(); try { return MAPPER.readValue(payloadJson, CreateRunRequest.class); } catch (Exception e) { log.warn("[ExternalApi] 解析 payload 失败,使用默认值: {}", e.getMessage()); return new CreateRunRequest(); } } private WorkflowRun findRunRecord(String runId) { return workflowRunRepository.findAllByOrderByStartedAtDesc().stream() .filter(r -> runId.equals(r.getRunId())) .findFirst().orElse(null); } @SuppressWarnings("unchecked") private Map parseOutputs(WorkflowRun record) { if (record.getOutputs() == null || record.getOutputs().isBlank()) return Map.of(); try { return MAPPER.readValue(record.getOutputs(), Map.class); } catch (Exception e) { return Map.of(); } } @SuppressWarnings("unchecked") private Map parseJsonToMap(String json) { if (json == null || json.isBlank()) return null; try { return MAPPER.readValue(json, Map.class); } catch (Exception e) { return null; } } private int countLogs(String logsJson) { if (logsJson == null || logsJson.isBlank()) return 0; try { return MAPPER.readTree(logsJson).size(); } catch (Exception e) { return 0; } } private List extractFields(JsonNode root, String nodeType) { List result = new ArrayList<>(); JsonNode nodes = root.path("nodes"); if (!nodes.isArray()) return result; for (JsonNode node : nodes) { if (!nodeType.equals(node.path("type").asText(""))) continue; JsonNode data = node.path("data"); JsonNode varArr = data.path("variables"); if (varArr.isArray()) { for (JsonNode v : varArr) { result.add(toFieldDef(v)); } } JsonNode fieldArr = data.path("fields"); if (fieldArr.isArray()) { for (JsonNode f : fieldArr) { result.add(toFieldDef(f)); } } } return result; } private FieldDef toFieldDef(JsonNode n) { FieldDef f = new FieldDef(); f.setName(n.path("name").asText("")); f.setType(n.path("type").asText("string")); f.setRequired(n.path("required").asBoolean(false)); f.setDescription(n.path("description").asText("")); return f; } private boolean shouldIncludeInZip(Path runDir, Path p) { Path rel = runDir.relativize(p); String relStr = rel.toString().replace('\\', '/'); if (agentIgnoreFilter.shouldIgnore(relStr, false)) return false; Path cur = rel; while (cur != null && cur.getNameCount() > 1) { cur = cur.getParent(); if (cur == null) break; if (agentIgnoreFilter.shouldIgnore(cur.toString().replace('\\', '/'), true)) return false; } return true; } }