198 lines
8.1 KiB
Java
198 lines
8.1 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.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<String> paths = Arrays.stream(pathsCsv.split(","))
|
|
.map(String::trim)
|
|
.filter(s -> !s.isEmpty())
|
|
.toList();
|
|
|
|
String snapshotKey = SNAPSHOT_PREFIX + pathsCsv.replaceAll("\\s+", "");
|
|
Map<String, JsonNode> previous = loadSnapshot(container, snapshotKey);
|
|
Map<String, JsonNode> 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<String, JsonNode> 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<String, Object> flat = MAPPER.readValue(json,
|
|
MAPPER.getTypeFactory().constructMapType(HashMap.class, String.class, Object.class));
|
|
Map<String, JsonNode> 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<String, JsonNode> 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);
|
|
}
|
|
}
|