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.engine.delegate.DelegateExecution; 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 Spring Boot component for tracking process variable changes across calls. * * Usage in Flowable expression: * ${variableTracker.track(execution, 'order.customer.name,order.total,status')} * * Returns a JSON string with an array of changed variables, each entry containing: * { "path": "order.customer.name", "oldValue": "Alice", "newValue": "Bob" } * * State (the snapshot of previous values) is stored as a process variable keyed by * SNAPSHOT_VAR_PREFIX + the provided paths string, so independent track() calls * with different path sets do not interfere with each other. */ @Component("variableTracker") public class VariableTrackerComponent { private static final Logger LOGGER = LoggerFactory.getLogger(VariableTrackerComponent.class); private static final String SNAPSHOT_VAR_PREFIX = "__vartracker__"; private final ObjectMapper objectMapper; public VariableTrackerComponent() { this.objectMapper = new ObjectMapper(); this.objectMapper.registerModule(new JavaTimeModule()); this.objectMapper.configure(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS, false); } /** * Checks which of the given variable paths changed since the last call and returns * a JSON array describing each change. * * @param execution the current Flowable execution context * @param pathsCsv comma-separated variable paths, e.g. "order.total,status,user.address.city" * @return JSON string — an array of { path, oldValue, newValue } objects for every changed path */ public String track(DelegateExecution execution, String pathsCsv) { if (pathsCsv == null || pathsCsv.isBlank()) { return "[]"; } List paths = Arrays.stream(pathsCsv.split(",")) .map(String::trim) .filter(s -> !s.isEmpty()) .toList(); String snapshotKey = SNAPSHOT_VAR_PREFIX + pathsCsv.replaceAll("\\s+", ""); Map previousSnapshot = loadSnapshot(execution, snapshotKey); Map currentSnapshot = new HashMap<>(); ArrayNode changes = objectMapper.createArrayNode(); for (String path : paths) { JsonNode currentValue = resolvePathToJson(execution, path); currentSnapshot.put(path, currentValue); if (previousSnapshot.isEmpty()) { // First call — record baseline, no changes reported continue; } JsonNode previousValue = previousSnapshot.getOrDefault(path, objectMapper.nullNode()); if (!jsonEquals(previousValue, currentValue)) { ObjectNode change = objectMapper.createObjectNode(); change.put("path", path); change.set("oldValue", previousValue); change.set("newValue", currentValue); changes.add(change); } } saveSnapshot(execution, snapshotKey, currentSnapshot); try { return objectMapper.writeValueAsString(changes); } catch (Exception e) { LOGGER.error("Failed to serialize changes to JSON", e); return "[]"; } } // ------------------------------------------------------------------------- // Variable path resolution // ------------------------------------------------------------------------- /** * Resolves a dot-separated path against the execution's variables and returns * the result as a JsonNode. The first segment is the top-level process variable * name; subsequent segments navigate into the object graph. */ private JsonNode resolvePathToJson(DelegateExecution execution, String path) { String[] segments = path.split("\\.", -1); Object current = execution.getVariable(segments[0]); for (int i = 1; i < segments.length; i++) { if (current == null) { return objectMapper.nullNode(); } current = getProperty(current, segments[i]); } return toJsonNode(current); } /** * Reads a named property from an object. Supports Maps, Jackson ObjectNodes, * and plain Java objects (via public getter or public field). */ @SuppressWarnings("unchecked") private Object getProperty(Object obj, String property) { if (obj instanceof Map map) { return map.get(property); } if (obj instanceof ObjectNode on) { JsonNode node = on.get(property); return node != null ? node : null; } if (obj instanceof JsonNode jn) { JsonNode node = jn.get(property); return node != null ? node : null; } // Reflection: try getter first, then field try { String getter = "get" + Character.toUpperCase(property.charAt(0)) + property.substring(1); Method method = obj.getClass().getMethod(getter); return method.invoke(obj); } catch (Exception ignored) { } try { String getter = "is" + Character.toUpperCase(property.charAt(0)) + property.substring(1); Method method = obj.getClass().getMethod(getter); return method.invoke(obj); } catch (Exception ignored) { } try { java.lang.reflect.Field field = findField(obj.getClass(), property); if (field != null) { field.setAccessible(true); return field.get(obj); } } catch (Exception ignored) { } LOGGER.warn("Could not resolve property '{}' on {}", property, obj.getClass().getName()); return null; } private 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 // ------------------------------------------------------------------------- @SuppressWarnings("unchecked") private Map loadSnapshot(DelegateExecution execution, String key) { Object raw = execution.getVariable(key); if (raw == null) { return new HashMap<>(); } try { String json = raw instanceof String s ? s : objectMapper.writeValueAsString(raw); Map flat = objectMapper.readValue(json, objectMapper.getTypeFactory().constructMapType(HashMap.class, String.class, Object.class)); Map result = new HashMap<>(); for (Map.Entry entry : flat.entrySet()) { result.put(entry.getKey(), toJsonNode(entry.getValue())); } return result; } catch (Exception e) { LOGGER.warn("Could not load variable tracker snapshot for key '{}': {}", key, e.getMessage()); return new HashMap<>(); } } private void saveSnapshot(DelegateExecution execution, String key, Map snapshot) { try { execution.setVariable(key, objectMapper.writeValueAsString(snapshot)); } catch (Exception e) { LOGGER.error("Could not save variable tracker snapshot for key '{}'", key, e); } } // ------------------------------------------------------------------------- // Helpers // ------------------------------------------------------------------------- private JsonNode toJsonNode(Object value) { if (value == null) { return objectMapper.nullNode(); } if (value instanceof JsonNode jn) { return jn; } return objectMapper.valueToTree(value); } private boolean jsonEquals(JsonNode a, JsonNode b) { if (a == null && b == null) return true; if (a == null || b == null) return false; return a.equals(b); } }