229 lines
8.7 KiB
Java
229 lines
8.7 KiB
Java
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<String> paths = Arrays.stream(pathsCsv.split(","))
|
|
.map(String::trim)
|
|
.filter(s -> !s.isEmpty())
|
|
.toList();
|
|
|
|
String snapshotKey = SNAPSHOT_VAR_PREFIX + pathsCsv.replaceAll("\\s+", "");
|
|
Map<String, JsonNode> previousSnapshot = loadSnapshot(execution, snapshotKey);
|
|
Map<String, JsonNode> 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<String, JsonNode> 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<String, Object> flat = objectMapper.readValue(json,
|
|
objectMapper.getTypeFactory().constructMapType(HashMap.class, String.class, Object.class));
|
|
Map<String, JsonNode> result = new HashMap<>();
|
|
for (Map.Entry<String, Object> 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<String, JsonNode> 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);
|
|
}
|
|
}
|