package jnpf.flowable.service.dev; import com.alibaba.fastjson.JSON; import jakarta.annotation.PreDestroy; import jnpf.base.UserInfo; import jnpf.config.ConfigValueUtil; import jnpf.database.util.TenantDataSourceUtil; import jnpf.exception.WorkFlowException; import jnpf.util.UserProvider; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import java.util.*; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.Semaphore; /** Development-only, process-local jobs. Browser disconnection never cancels a running job. */ @Service @RequiredArgsConstructor @Slf4j public class DevApprovalRuns { private final DevApprovalService service; private final DevApprovalSignatures signatures; private final ConfigValueUtil config; private final Map runs = new ConcurrentHashMap<>(); private final Semaphore capacity = new Semaphore(4); private volatile boolean closing; private java.util.concurrent.Executor executor = command -> Thread.ofPlatform().name("workflow-dev-approval") .daemon(true).inheritInheritableThreadLocals(false).start(command); public record Progress(String runId, String status, int completed, String nodeName, String message) {} private static class Run { final String id = UUID.randomUUID().toString(); final String owner; final String signId; final long created = System.currentTimeMillis(); volatile boolean stop; volatile Progress progress; Run(String owner, String signId) { this.owner = owner; this.signId = signId; progress = new Progress(id, "RUNNING", 0, null, "后台审批已启动"); } boolean active() { return Set.of("RUNNING", "STOPPING").contains(progress.status()); } } private UserInfo requireUser() throws WorkFlowException { if (closing || !service.allowed()) throw new WorkFlowException("开发后台审批不可用或无权限"); return UserProvider.getUser(); } private String key(String taskId, UserInfo user) { String tenant = user.getTenantId(); return (tenant == null || tenant.isBlank() ? "0" : tenant) + ":" + taskId; } private void owner(Run run, UserInfo user) throws WorkFlowException { if (!run.owner.equals(user.getUserId())) throw new WorkFlowException("此后台任务不属于当前用户"); } public synchronized Progress start(String taskId, String signId) throws WorkFlowException { UserInfo caller = requireUser(); Run existing = runs.get(key(taskId, caller)); if (existing != null && (existing.active() || Objects.equals(existing.signId, signId))) { owner(existing, caller); return existing.progress; } signatures.require(taskId, signId); var state = service.inspect(taskId, null); if (!state.isEnabled() || !"READY".equals(state.getStatus())) throw new WorkFlowException("流程当前不可自动审批:" + String.join(";", state.getProblems())); if (!capacity.tryAcquire()) throw new WorkFlowException("后台审批任务已满,请稍后再试"); Run run = new Run(caller.getUserId(), signId); try { UserInfo captured = JSON.parseObject(JSON.toJSONString(caller), UserInfo.class); if (captured.getToken() == null || captured.getToken().isBlank()) captured.setToken(UserProvider.getToken()); if (captured.getToken() == null || captured.getToken().isBlank()) throw new WorkFlowException("登录凭证不可用,请重新登录"); runs.entrySet().removeIf(e -> !e.getValue().active() && System.currentTimeMillis() - e.getValue().created > 60 * 60 * 1000); if (runs.size() >= 200) throw new WorkFlowException("后台审批记录已满,请稍后再试"); runs.put(key(taskId, caller), run); executor.execute(() -> execute(taskId, run, captured)); return run.progress; } catch (Exception ex) { runs.remove(key(taskId, caller), run); capacity.release(); throw ex; } } public Progress status(String taskId) throws WorkFlowException { UserInfo user = requireUser(); Run run = runs.get(key(taskId, user)); if (run == null) return new Progress(null, "IDLE", 0, null, null); owner(run, user); return run.progress; } public synchronized Progress stop(String taskId, String runId) throws WorkFlowException { UserInfo user = requireUser(); Run run = runs.get(key(taskId, user)); if (run == null || !run.id.equals(runId)) throw new WorkFlowException("后台任务已变化,请刷新状态"); owner(run, user); if (run.active()) { run.stop = true; run.progress = new Progress(run.id, "STOPPING", run.progress.completed(), run.progress.nodeName(), "当前节点处理完后停止"); } return run.progress; } private void execute(String taskId, Run run, UserInfo user) { int completed = 0; String node = null; Set handled = new HashSet<>(); try { UserProvider.setLocalLoginUser(user); if (config.isMultiTenancy()) TenantDataSourceUtil.switchTenant(user.getTenantId()); while (!run.stop && !closing && completed < 100 && System.currentTimeMillis() - run.created < 25 * 60 * 1000) { if (!Boolean.TRUE.equals(UserProvider.isValidToken(user.getToken()))) throw new WorkFlowException("登录已失效,后台审批已停止,请重新登录并签名"); // No signed HTTP preflight: approve performs one full preflight under the native node lock. var result = service.approve(taskId, null, run.signId); node = result.getNodeName(); if (!Set.of("ADVANCED", "PASSED").contains(result.getStatus())) { finish(run, "BLOCKED", completed, node, String.join(";", result.getProblems())); return; } if (result.getOperatorId() != null) { if (!handled.add(result.getOperatorId())) throw new WorkFlowException("待办未推进,后台审批已停止"); completed++; } if ("PASSED".equals(result.getStatus())) { finish(run, "PASSED", completed, node, "流程已审核通过"); return; } run.progress = new Progress(run.id, run.stop ? "STOPPING" : "RUNNING", completed, node, null); } finish(run, "STOPPED", completed, node, run.stop || closing ? "后台审批已停止" : "达到本轮执行上限,请查看流程后重新签名"); } catch (Exception ex) { log.error("开发后台审批中断 task={} run={} completed={}", taskId, run.id, completed, ex); finish(run, "FAILED", completed, node, ex instanceof WorkFlowException ? ex.getMessage() : "后台审批异常,请查看服务日志并刷新流程核对已完成节点"); } finally { DevApprovalService.clearContext(); UserProvider.clearLocalUser(); TenantDataSourceUtil.clearLocalTenantInfo(); capacity.release(); } } private synchronized void finish(Run run, String status, int completed, String node, String message) { run.progress = new Progress(run.id, status, completed, node, message); } @PreDestroy public void shutdown() { closing = true; runs.values().forEach(run -> run.stop = true); } }