package com.customer.work.service; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.SerializationFeature; import com.fasterxml.jackson.databind.node.ArrayNode; import com.fasterxml.jackson.databind.node.ObjectNode; import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; import org.flowable.cmmn.engine.CmmnEngineConfiguration; import org.flowable.common.engine.api.delegate.event.FlowableEntityEvent; import org.flowable.common.engine.api.delegate.event.FlowableEvent; import org.flowable.common.engine.api.delegate.event.FlowableEventListener; import org.flowable.common.engine.api.variable.VariableContainer; import org.flowable.engine.ProcessEngineConfiguration; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.SmartInitializingSingleton; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Component; import java.util.Arrays; import java.util.HashMap; import java.util.List; import java.util.Map; /** * General-purpose Flowable variable utility bean. * * Usable in BPMN process and CMMN case backend expressions without any * scope parameter — the current execution / plan-item instance is captured * automatically via a Flowable event listener registered on both engines. * * Expression examples: * ${varUtils.get('order.customer.name')} * ${varUtils.track('order.customer.name,order.total,status')} * * How the scope is resolved without a parameter * ----------------------------------------------- * Flowable fires ACTIVITY_STARTED (BPMN) or PLAN_ITEM_INSTANCE_STARTED (CMMN) * on the same thread — and strictly before — the service-task expression is * evaluated. onEvent() stores the current VariableContainer in a ThreadLocal. * The ThreadLocal is cleared when the activity / plan item completes or is * cancelled, so it never leaks across tasks. * * Registration * ------------ * SmartInitializingSingleton.afterSingletonsInstantiated() runs after all * Spring beans (including engine configurations) are fully initialised, so * addEventListener() is always called on a live event dispatcher. * Only the engine configurations that are present in the application context * are registered; the other one is silently skipped. */ @Component("varUtils") public class VarUtils implements FlowableEventListener, SmartInitializingSingleton { private static final Logger LOGGER = LoggerFactory.getLogger(VarUtils.class); private static final String SNAPSHOT_PREFIX = "__vartracker__"; private static final ThreadLocal SCOPE = new ThreadLocal<>(); private static final ObjectMapper MAPPER; static { MAPPER = new ObjectMapper(); MAPPER.registerModule(new JavaTimeModule()); MAPPER.configure(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS, false); } @Autowired(required = false) private ProcessEngineConfiguration processEngineConfiguration; @Autowired(required = false) private CmmnEngineConfiguration cmmnEngineConfiguration; // ------------------------------------------------------------------------- // Listener registration // ------------------------------------------------------------------------- @Override public void afterSingletonsInstantiated() { if (processEngineConfiguration != null) { processEngineConfiguration.getEventDispatcher().addEventListener(this); LOGGER.debug("varUtils registered on BPMN event dispatcher"); } if (cmmnEngineConfiguration != null) { cmmnEngineConfiguration.getEventDispatcher().addEventListener(this); LOGGER.debug("varUtils registered on CMMN event dispatcher"); } } // ------------------------------------------------------------------------- // FlowableEventListener — bind / unbind the scope ThreadLocal // ------------------------------------------------------------------------- @Override public void onEvent(FlowableEvent event) { if (!(event instanceof FlowableEntityEvent entityEvent)) return; Object entity = entityEvent.getEntity(); if (!(entity instanceof VariableContainer vc)) return; String eventName = event.getType().name(); LOGGER.info("eventName={}", eventName); switch (eventName) { case "CASE_STARTED", "PROCESS_STARTED", "ACTIVITY_STARTED", "PLAN_ITEM_INSTANCE_STARTED" -> SCOPE.set(vc); case "CASE_ENDED", "PROCESS_COMPLETED", "ACTIVITY_COMPLETED", "ACTIVITY_CANCELLED", "PLAN_ITEM_INSTANCE_COMPLETED", "PLAN_ITEM_INSTANCE_TERMINATED", "PLAN_ITEM_INSTANCE_SUSPENDED" -> SCOPE.remove(); default -> { /* ignore */ } } } @Override public boolean isFailOnException() { return false; } @Override public boolean isFireOnTransactionLifecycleEvent() { return false; } @Override public String getOnTransaction() { return null; } // ------------------------------------------------------------------------- // Public API — called from Flowable expressions // ------------------------------------------------------------------------- /** * Returns the value at the given dot-separated path within the current * variable scope. Supports Maps, Lists (integer index), Jackson JsonNodes, * and POJOs (getter or field). Returns null for any missing segment. * * ${varUtils.get('order.customer.name')} * ${varUtils.get('order.lines.0.price')} */ public Object get(String path) { VariableContainer scope = currentScope("get"); if (scope == null || path == null || path.isBlank()) return null; return resolvePath(scope, path.trim()); } /** * Checks which of the given comma-separated variable paths changed since the * last call and returns a JSON array describing each change. * * ${varUtils.track('order.customer.name,order.total,status')} * * Returns [] on the first call (baseline snapshot recorded). * Each subsequent call compares against the previous snapshot and updates it. * The snapshot is stored inside the variable scope so it survives across tasks. * * Example return value: * [{"path":"order.total","oldValue":100,"newValue":150}] */ public String track(String pathsCsv) { VariableContainer scope = currentScope("track"); if (scope == null || pathsCsv == null || pathsCsv.isBlank()) return "[]"; List paths = Arrays.stream(pathsCsv.split(",")) .map(String::trim).filter(s -> !s.isEmpty()).toList(); String snapshotKey = SNAPSHOT_PREFIX + pathsCsv.replaceAll("\\s+", ""); Map previous = loadSnapshot(scope, snapshotKey); Map current = new HashMap<>(); ArrayNode changes = MAPPER.createArrayNode(); for (String path : paths) { JsonNode value = toJson(resolvePath(scope, path)); current.put(path, value); if (previous.isEmpty()) continue; // first call — baseline only JsonNode prev = previous.getOrDefault(path, MAPPER.nullNode()); if (!prev.equals(value)) { ObjectNode change = MAPPER.createObjectNode(); change.put("path", path); change.set("oldValue", prev); change.set("newValue", value); changes.add(change); } } saveSnapshot(scope, snapshotKey, current); try { return MAPPER.writeValueAsString(changes); } catch (Exception e) { LOGGER.error("varUtils.track: failed to serialize changes", e); return "[]"; } } // ------------------------------------------------------------------------- // Path resolution // ------------------------------------------------------------------------- private static Object resolvePath(VariableContainer scope, String path) { String[] segments = path.split("\\.", -1); Object current = scope.getVariable(segments[0]); for (int i = 1; i < segments.length; i++) { if (current == null) return null; current = step(current, segments[i]); } return current; } @SuppressWarnings("unchecked") private static Object step(Object obj, String segment) { if (obj instanceof List list) { try { int i = Integer.parseInt(segment); return i >= 0 && i < list.size() ? list.get(i) : null; } catch (NumberFormatException ignored) {} } if (obj instanceof Object[] arr) { try { int i = Integer.parseInt(segment); return i >= 0 && i < arr.length ? arr[i] : null; } catch (NumberFormatException ignored) {} } if (obj instanceof Map map) return map.get(segment); if (obj instanceof JsonNode jn) { JsonNode node = jn.get(segment); if (node == null || node.isNull()) return null; if (node.isTextual()) return node.asText(); if (node.isBoolean()) return node.asBoolean(); if (node.isLong()) return node.asLong(); if (node.isInt()) return node.asInt(); if (node.isDouble()) return node.asDouble(); return node; } // POJO: try getter (getX / isX) then field String cap = Character.toUpperCase(segment.charAt(0)) + segment.substring(1); try { return obj.getClass().getMethod("get" + cap).invoke(obj); } catch (Exception ignored) {} try { return obj.getClass().getMethod("is" + cap).invoke(obj); } catch (Exception ignored) {} try { java.lang.reflect.Field f = findField(obj.getClass(), segment); if (f != null) { f.setAccessible(true); return f.get(obj); } } catch (Exception ignored) {} LOGGER.warn("varUtils: cannot resolve '{}' on {}", segment, obj.getClass().getName()); return null; } private static java.lang.reflect.Field findField(Class c, String name) { while (c != null && c != Object.class) { try { return c.getDeclaredField(name); } catch (NoSuchFieldException e) { c = c.getSuperclass(); } } return null; } // ------------------------------------------------------------------------- // Snapshot persistence // ------------------------------------------------------------------------- private static Map loadSnapshot(VariableContainer scope, String key) { Object raw = scope.getVariable(key); if (raw == null) return new HashMap<>(); try { String json = raw instanceof String s ? s : MAPPER.writeValueAsString(raw); Map flat = MAPPER.readValue(json, MAPPER.getTypeFactory().constructMapType(HashMap.class, String.class, Object.class)); Map result = new HashMap<>(); flat.forEach((k, v) -> result.put(k, toJson(v))); return result; } catch (Exception e) { LOGGER.warn("varUtils: could not load snapshot '{}': {}", key, e.getMessage()); return new HashMap<>(); } } private static void saveSnapshot(VariableContainer scope, String key, Map snapshot) { try { scope.setVariable(key, MAPPER.writeValueAsString(snapshot)); } catch (Exception e) { LOGGER.error("varUtils: could not save snapshot '{}'", key, e); } } // ------------------------------------------------------------------------- // Helpers // ------------------------------------------------------------------------- private VariableContainer currentScope(String method) { VariableContainer scope = SCOPE.get(); if (scope == null) { LOGGER.error("varUtils.{}() called without an active Flowable scope — " + "is VarUtils registered on the engine event dispatcher?", method); } return scope; } private static JsonNode toJson(Object value) { if (value == null) return MAPPER.nullNode(); if (value instanceof JsonNode jn) return jn; return MAPPER.valueToTree(value); } }