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.common.engine.api.variable.VariableContainer; import org.flowable.common.engine.impl.el.function.AbstractFlowableVariableExpressionFunction; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.stereotype.Component; import java.lang.reflect.Method; import java.util.Arrays; import java.util.HashMap; import java.util.List; import java.util.Map; /** * Flowable EL function delegate that tracks process / case variable changes across calls. * Works in both BPMN processes and CMMN cases without any explicit scope parameter. * * Flowable's AbstractFlowableVariableExpressionFunction automatically injects the current * VariableContainer as the first argument, so no ThreadLocal, listener, or auto-config * is required. Registered automatically when Flowable finds a FlowableFunctionDelegate * bean in the application context. * * Usage in a Flowable backend expression (prefixes var / vars / variables all work): * ${var:track('order.customer.name,order.total,status')} * * Returns a JSON string with an array of entries for every changed variable: * [{ "path": "order.total", "oldValue": 100, "newValue": 150 }, ...] * * The first call records a baseline snapshot and returns []. * Subsequent calls compare current values against that baseline and update it. * The snapshot is stored inside the variable scope under a hidden key so it persists * across activity boundaries within the same process / case instance. */ @Component public class VariableTrackerComponent extends AbstractFlowableVariableExpressionFunction { private static final Logger LOGGER = LoggerFactory.getLogger(VariableTrackerComponent.class); private static final String SNAPSHOT_PREFIX = "__vartracker__"; // ObjectMapper is thread-safe after construction private static final ObjectMapper MAPPER; static { MAPPER = new ObjectMapper(); MAPPER.registerModule(new JavaTimeModule()); MAPPER.configure(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS, false); } public VariableTrackerComponent() { super(List.of("track"), "track"); } // ------------------------------------------------------------------------- // EL function — called by Flowable's expression engine // ------------------------------------------------------------------------- /** * {@code container} is injected automatically by Flowable's AST rewriter. * {@code pathsCsv} is the comma-separated path string from the expression. */ public static String track(VariableContainer container, String pathsCsv) { if (container == 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(container, snapshotKey); Map current = new HashMap<>(); ArrayNode changes = MAPPER.createArrayNode(); for (String path : paths) { JsonNode value = resolveToJson(container, path); current.put(path, value); if (previous.isEmpty()) { continue; // first call — record baseline only, report nothing } 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(container, snapshotKey, current); try { return MAPPER.writeValueAsString(changes); } catch (Exception e) { LOGGER.error("Failed to serialize variable changes", e); return "[]"; } } // ------------------------------------------------------------------------- // Path resolution // ------------------------------------------------------------------------- private static JsonNode resolveToJson(VariableContainer container, String path) { String[] segments = path.split("\\.", -1); Object current = container.getVariable(segments[0]); for (int i = 1; i < segments.length; i++) { if (current == null) return MAPPER.nullNode(); current = step(current, segments[i]); } return toJson(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) return jn.get(segment); // POJO: getter then field try { return obj.getClass().getMethod("get" + cap(segment)).invoke(obj); } catch (Exception ignored) {} try { return obj.getClass().getMethod("is" + cap(segment)).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("variableTracker: cannot resolve segment '{}' on {}", segment, obj.getClass().getName()); return null; } private static String cap(String s) { return Character.toUpperCase(s.charAt(0)) + s.substring(1); } private static java.lang.reflect.Field findField(Class clazz, String name) { while (clazz != null && clazz != Object.class) { try { return clazz.getDeclaredField(name); } catch (NoSuchFieldException e) { clazz = clazz.getSuperclass(); } } return null; } // ------------------------------------------------------------------------- // Snapshot persistence inside the variable scope // ------------------------------------------------------------------------- private static Map loadSnapshot(VariableContainer container, String key) { Object raw = container.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("variableTracker: could not load snapshot '{}': {}", key, e.getMessage()); return new HashMap<>(); } } private static void saveSnapshot(VariableContainer container, String key, Map snapshot) { try { container.setVariable(key, MAPPER.writeValueAsString(snapshot)); } catch (Exception e) { LOGGER.error("variableTracker: could not save snapshot '{}'", key, e); } } private static JsonNode toJson(Object value) { if (value == null) return MAPPER.nullNode(); if (value instanceof JsonNode jn) return jn; return MAPPER.valueToTree(value); } }