425 lines
19 KiB
Java
425 lines
19 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.cmmn.api.CmmnRuntimeService;
|
|
import org.flowable.cmmn.api.delegate.DelegatePlanItemInstance;
|
|
import org.flowable.cmmn.engine.CmmnEngineConfiguration;
|
|
import org.flowable.common.engine.api.delegate.event.FlowableEngineEntityEvent;
|
|
import org.flowable.common.engine.api.delegate.event.FlowableEvent;
|
|
import org.flowable.common.engine.api.delegate.event.FlowableEventListener;
|
|
import org.flowable.engine.ProcessEngineConfiguration;
|
|
import org.flowable.cmmn.api.runtime.CaseInstance;
|
|
import org.flowable.cmmn.api.runtime.PlanItemInstance;
|
|
import org.flowable.engine.RuntimeService;
|
|
import org.flowable.engine.delegate.DelegateExecution;
|
|
import org.flowable.engine.delegate.event.FlowableProcessEngineEvent;
|
|
import org.flowable.engine.runtime.Execution;
|
|
import org.flowable.engine.runtime.ProcessInstance;
|
|
import org.flowable.variable.api.delegate.VariableScope;
|
|
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:
|
|
* ${varUtils.get('order.customer.name')}
|
|
* ${varUtils.track('order.customer.name,order.total,status')}
|
|
*
|
|
* How the scope is resolved without a parameter
|
|
* -----------------------------------------------
|
|
* The bean registers itself as a Flowable event listener on both engines.
|
|
* Before a service task expression is evaluated, Flowable fires events on the
|
|
* same thread that allow us to capture the current variable scope:
|
|
*
|
|
* BPMN — ACTIVITY_STARTED fires a FlowableProcessEngineEvent; the execution
|
|
* is retrieved via event.getExecution().
|
|
*
|
|
* CMMN — There is no PLAN_ITEM_INSTANCE_STARTED event. Instead, when a plan
|
|
* item instance transitions to the ACTIVE state, the base entity manager
|
|
* calls update(), which dispatches ENTITY_UPDATED carrying the
|
|
* PlanItemInstanceEntity (implements DelegatePlanItemInstance).
|
|
* We bind the scope when the state is "active" and clear it when the
|
|
* state reaches a terminal value.
|
|
*/
|
|
@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<VariableScope> 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;
|
|
|
|
@Autowired(required = false)
|
|
private RuntimeService runtimeService;
|
|
|
|
@Autowired(required = false)
|
|
private CmmnRuntimeService cmmnRuntimeService;
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Listener registration — runs after all beans are ready
|
|
// -------------------------------------------------------------------------
|
|
|
|
@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) {
|
|
String typeName = event.getType().name();
|
|
|
|
// BPMN — ACTIVITY_STARTED fires FlowableActivityEventImpl which implements
|
|
// FlowableProcessEngineEvent (not FlowableEntityEvent as one might expect).
|
|
// The execution is accessed via getExecution(), not getEntity().
|
|
if (event instanceof FlowableProcessEngineEvent pe) {
|
|
DelegateExecution execution = pe.getExecution();
|
|
if (execution == null) return;
|
|
switch (typeName) {
|
|
case "CASE_STARTED", "PROCESS_STARTED" -> SCOPE.set(execution);
|
|
case "CASE_ENDED", "PROCESS_COMPLETED" -> SCOPE.remove();
|
|
}
|
|
return;
|
|
}
|
|
|
|
// CMMN — There is no dedicated PLAN_ITEM_INSTANCE_STARTED event type.
|
|
// The base AbstractEntityManager.update() dispatches ENTITY_UPDATED whenever
|
|
// a plan item instance's state is persisted. We filter on DelegatePlanItemInstance
|
|
// and check the state string to bind / unbind the scope.
|
|
if ("ENTITY_UPDATED".equals(typeName) && event instanceof FlowableEngineEntityEvent ee) {
|
|
Object entity = ee.getEntity();
|
|
if (entity instanceof DelegatePlanItemInstance dpi) {
|
|
String state = dpi.getState();
|
|
if ("active".equals(state)) {
|
|
SCOPE.set(dpi);
|
|
} else if ("completed".equals(state) || "terminated".equals(state)
|
|
|| "failed".equals(state) || "suspended".equals(state)) {
|
|
SCOPE.remove();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
@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) {
|
|
VariableScope scope = currentScope("get");
|
|
if (scope == null || path == null || path.isBlank()) return null;
|
|
return resolvePath(scope, path.trim(), this);
|
|
}
|
|
|
|
/**
|
|
* 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) {
|
|
VariableScope scope = currentScope("track");
|
|
if (scope == 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(scope, snapshotKey);
|
|
Map<String, JsonNode> current = new HashMap<>();
|
|
ArrayNode changes = MAPPER.createArrayNode();
|
|
|
|
for (String path : paths) {
|
|
JsonNode value = toJson(resolvePath(scope, path, this));
|
|
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
|
|
// -------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Resolves a dot-separated path against the current scope.
|
|
*
|
|
* If the first segment is the reserved word {@code root}, the entire call
|
|
* hierarchy is climbed until no parent can be found; the next segment is
|
|
* then used as the variable name at that topmost scope.
|
|
*
|
|
* root.myString → topmost scope, variable "myString"
|
|
* root.myObj.field → topmost scope, variable "myObj", then navigate to "field"
|
|
* order.customer.name → current scope, variable "order", navigate normally
|
|
*/
|
|
private static Object resolvePath(VariableScope scope, String path, VarUtils self) {
|
|
String[] segments = path.split("\\.", -1);
|
|
Object current;
|
|
int startIdx;
|
|
|
|
if ("root".equals(segments[0])) {
|
|
if (segments.length < 2) return null;
|
|
// climb all the way up, then get the variable named by segments[1]
|
|
current = self.getFromRootScope(scope, segments[1]);
|
|
startIdx = 2;
|
|
} else {
|
|
current = scope.getVariable(segments[0]);
|
|
startIdx = 1;
|
|
}
|
|
|
|
for (int i = startIdx; i < segments.length; i++) {
|
|
if (current == null) return null;
|
|
current = step(current, segments[i]);
|
|
}
|
|
return current;
|
|
}
|
|
|
|
/** Climbs to the topmost ancestor scope and returns the named variable from there. */
|
|
private Object getFromRootScope(VariableScope scope, String varName) {
|
|
if (scope instanceof DelegateExecution ex && runtimeService != null)
|
|
return getFromBpmnRoot(ex.getProcessInstanceId(), varName);
|
|
if (scope instanceof DelegatePlanItemInstance dpi && cmmnRuntimeService != null)
|
|
return getFromCmmnRoot(dpi.getCaseInstanceId(), varName);
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Climbs the hierarchy from a BPMN process instance toward the root.
|
|
* Parent links:
|
|
* getSuperExecutionId() → launched via Call Activity (parent is BPMN)
|
|
* getCallbackId/Type() → launched via Case Task (parent is CMMN)
|
|
* When no parent exists this IS the root; the variable is fetched here.
|
|
*/
|
|
private Object getFromBpmnRoot(String processInstanceId, String varName) {
|
|
if (processInstanceId == null || runtimeService == null) return null;
|
|
try {
|
|
// Parent via Call Activity (BPMN → BPMN)
|
|
Execution piExec = runtimeService.createExecutionQuery()
|
|
.executionId(processInstanceId).singleResult();
|
|
if (piExec != null && piExec.getSuperExecutionId() != null) {
|
|
Execution superExec = runtimeService.createExecutionQuery()
|
|
.executionId(piExec.getSuperExecutionId()).singleResult();
|
|
if (superExec != null)
|
|
return getFromBpmnRoot(superExec.getProcessInstanceId(), varName);
|
|
}
|
|
|
|
// Parent via Case Task (CMMN → BPMN): callbackId = plan item instance id
|
|
ProcessInstance pi = runtimeService.createProcessInstanceQuery()
|
|
.processInstanceId(processInstanceId).singleResult();
|
|
if (pi != null && pi.getCallbackType() != null && pi.getCallbackId() != null
|
|
&& cmmnRuntimeService != null) {
|
|
PlanItemInstance planItem = cmmnRuntimeService.createPlanItemInstanceQuery()
|
|
.planItemInstanceId(pi.getCallbackId()).singleResult();
|
|
if (planItem != null)
|
|
return getFromCmmnRoot(planItem.getCaseInstanceId(), varName);
|
|
}
|
|
|
|
// No parent reachable — this is the root BPMN process instance
|
|
return runtimeService.getVariable(processInstanceId, varName);
|
|
} catch (Exception e) {
|
|
LOGGER.debug("varUtils: BPMN root climb failed for '{}': {}", varName, e.getMessage());
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Climbs the hierarchy from a CMMN case instance toward the root.
|
|
* Parent links:
|
|
* getParentId() → launched via Case Task in another case (parent is CMMN)
|
|
* getCallbackId/Type() → launched via Case Task in a process (parent is BPMN)
|
|
* When no parent exists this IS the root; the variable is fetched here.
|
|
*/
|
|
private Object getFromCmmnRoot(String caseInstanceId, String varName) {
|
|
if (caseInstanceId == null || cmmnRuntimeService == null) return null;
|
|
try {
|
|
CaseInstance ci = cmmnRuntimeService.createCaseInstanceQuery()
|
|
.caseInstanceId(caseInstanceId).singleResult();
|
|
if (ci == null) return null;
|
|
|
|
// Parent via Case Task in another case (CMMN → CMMN)
|
|
if (ci.getParentId() != null)
|
|
return getFromCmmnRoot(ci.getParentId(), varName);
|
|
|
|
// Parent via Case Task in a process (BPMN → CMMN): callbackId = execution id
|
|
if (ci.getCallbackType() != null && ci.getCallbackId() != null
|
|
&& runtimeService != null) {
|
|
Execution callbackExec = runtimeService.createExecutionQuery()
|
|
.executionId(ci.getCallbackId()).singleResult();
|
|
if (callbackExec != null)
|
|
return getFromBpmnRoot(callbackExec.getProcessInstanceId(), varName);
|
|
}
|
|
|
|
// No parent reachable — this is the root CMMN case instance
|
|
return cmmnRuntimeService.getVariable(caseInstanceId, varName);
|
|
} catch (Exception e) {
|
|
LOGGER.debug("varUtils: CMMN root climb failed for '{}': {}", varName, e.getMessage());
|
|
return null;
|
|
}
|
|
}
|
|
|
|
@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;
|
|
}
|
|
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<String, JsonNode> loadSnapshot(VariableScope 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<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("varUtils: could not load snapshot '{}': {}", key, e.getMessage());
|
|
return new HashMap<>();
|
|
}
|
|
}
|
|
|
|
private static void saveSnapshot(VariableScope scope, String key, Map<String, JsonNode> snapshot) {
|
|
try {
|
|
scope.setVariable(key, MAPPER.writeValueAsString(snapshot));
|
|
} catch (Exception e) {
|
|
LOGGER.error("varUtils: could not save snapshot '{}'", key, e);
|
|
}
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Helpers
|
|
// -------------------------------------------------------------------------
|
|
|
|
private VariableScope currentScope(String method) {
|
|
VariableScope 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);
|
|
}
|
|
}
|