diff --git a/common/common-flow/src/main/java/apelet/common/flow/controller/FlowOperationController.java b/common/common-flow/src/main/java/apelet/common/flow/controller/FlowOperationController.java
index 43d5582..4b39b89 100644
--- a/common/common-flow/src/main/java/apelet/common/flow/controller/FlowOperationController.java
+++ b/common/common-flow/src/main/java/apelet/common/flow/controller/FlowOperationController.java
@@ -2,6 +2,7 @@ package apelet.common.flow.controller;
import apelet.common.core.annotation.DisableDataFilter;
import apelet.common.core.annotation.MyRequestBody;
+import apelet.common.core.annotation.NoAuthInterface;
import apelet.common.core.constant.ErrorCodeEnum;
import apelet.common.core.object.*;
import apelet.common.core.util.MyPageUtil;
@@ -16,10 +17,7 @@ import apelet.common.flow.object.FlowEntryExtensionData;
import apelet.common.flow.service.*;
import apelet.common.flow.util.FlowCustomExtFactory;
import apelet.common.flow.util.FlowOperationHelper;
-import apelet.common.flow.vo.FlowTaskCommentVo;
-import apelet.common.flow.vo.FlowTaskVo;
-import apelet.common.flow.vo.FlowUserInfoVo;
-import apelet.common.flow.vo.TaskInfoVo;
+import apelet.common.flow.vo.*;
import apelet.common.log.annotation.OperationLog;
import apelet.common.log.model.constant.SysOperationLogType;
import cn.hutool.core.bean.BeanUtil;
@@ -79,6 +77,10 @@ public class FlowOperationController {
@Autowired
private FlowWorkOrderService flowWorkOrderService;
@Autowired
+ private FlowTaskInstanceExtService flowTaskInstanceExtService;
+ @Autowired
+ private FlowTaskTitleBackfillService flowTaskTitleBackfillService;
+ @Autowired
private FlowMessageService flowMessageService;
@Autowired
private FlowOperationHelper flowOperationHelper;
@@ -218,10 +220,12 @@ public class FlowOperationController {
taskInfoVo.setVariableList(JSON.parseArray(flowTaskExt.getVariableListJson(), JSONObject.class));
}
FlowWorkOrder workOrder = flowWorkOrderService.getFlowWorkOrderByProcessInstanceId(processInstanceId);
- FlowEntry flowEntry = workOrder == null ? new FlowEntry() : flowEntryService.getFlowEntryFromCache(workOrder.getProcessDefinitionKey());
- // 单条详情:直接单查一次扩展对象即可,无 N+1 风险。
- FlowWorkOrderExt workOrderExt = workOrder == null ? null : flowWorkOrderService.getFlowWorkOrderExtByWorkOrderId(workOrder.getWorkOrderId());
- taskInfoVo.setTitle(flowApiService.resolveTitleCached(flowTaskExt.getTitleTemplate(), processInstanceId, workOrder, flowEntry, taskInfoVo.getTaskKey(), workOrderExt));
+ // 单条详情:只查一次节点扩展(复用批量接口传单元素集合),无 N+1 风险。
+ String taskKey = taskInfoVo.getTaskKey();
+ FlowTaskInstanceExt taskInstanceExt = workOrder == null || StrUtil.isBlank(taskKey) ? null
+ : flowTaskInstanceExtService.getMapByProcessInstanceIds(CollUtil.newHashSet(processInstanceId))
+ .get(processInstanceId + ":" + taskKey);
+ taskInfoVo.setTitle(flowApiService.resolveTitleCached(workOrder, taskKey, taskInstanceExt));
}
return ResponseResult.success(taskInfoVo);
}
@@ -540,6 +544,7 @@ public class FlowOperationController {
* @param processDefinitionKey 流程标识。
* @param processDefinitionName 流程定义名 (模糊查询)。
* @param taskName 任务名称 (模糊查询)。
+ * @param title 节点标题关键字,模糊匹配。
* @param pageParam 分页对象。
* @return 返回当前用户待办的任务列表。如果指定流程标识,则仅返回该流程的待办任务列表。
*/
@@ -549,10 +554,11 @@ public class FlowOperationController {
@MyRequestBody String processDefinitionKey,
@MyRequestBody String processDefinitionName,
@MyRequestBody String taskName,
+ @MyRequestBody String title,
@MyRequestBody(required = true) MyPageParam pageParam) {
String username = TokenData.takeFromRequest().getLoginName();
MyPageData pageData = flowApiService.getTaskListByUserName(
- username, processDefinitionKey, processDefinitionName, taskName, pageParam);
+ username, processDefinitionKey, processDefinitionName, taskName, title, pageParam);
List flowTaskVoList = flowApiService.convertToFlowTaskList(pageData.getDataList());
return ResponseResult.success(MyPageUtil.makeResponseData(flowTaskVoList, pageData.getTotalCount()));
}
@@ -811,6 +817,7 @@ public class FlowOperationController {
* @param processDefinitionName 流程名。
* @param beginDate 流程发起开始时间。
* @param endDate 流程发起结束时间。
+ * @param title 节点标题关键字,模糊匹配。
* @param pageParam 分页对象。
* @return 查询结果应答。
*/
@@ -820,9 +827,10 @@ public class FlowOperationController {
@MyRequestBody String processDefinitionName,
@MyRequestBody String beginDate,
@MyRequestBody String endDate,
+ @MyRequestBody String title,
@MyRequestBody(required = true) MyPageParam pageParam) throws ParseException {
MyPageData pageData =
- flowApiService.getHistoricTaskInstanceFinishedList(processDefinitionName, beginDate, endDate, pageParam);
+ flowApiService.getHistoricTaskInstanceFinishedList(processDefinitionName, beginDate, endDate, title, pageParam);
List
+ *
+ * @param delegateTask 当前任务对象。
*/
private void buildTaskTitle(DelegateTask delegateTask) {
try {
@@ -57,7 +59,7 @@ public class FlowUserTaskListener implements TaskListener {
workOrder, delegateTask.getProcessDefinitionId(), delegateTask.getTaskDefinitionKey());
} catch (Exception e) {
// 标题属于展示字段,生成失败不能阻断流程流转。
- log.warn("生成节点标题快照失败。processInstanceId [{}], taskKey [{}]",
+ log.warn("生成节点标题失败。processInstanceId [{}], taskKey [{}]",
delegateTask.getProcessInstanceId(), delegateTask.getTaskDefinitionKey(), e);
}
}
diff --git a/common/common-flow/src/main/java/apelet/common/flow/model/FlowTaskInstanceExt.java b/common/common-flow/src/main/java/apelet/common/flow/model/FlowTaskInstanceExt.java
new file mode 100644
index 0000000..864373c
--- /dev/null
+++ b/common/common-flow/src/main/java/apelet/common/flow/model/FlowTaskInstanceExt.java
@@ -0,0 +1,86 @@
+package apelet.common.flow.model;
+
+import com.baomidou.mybatisplus.annotation.TableField;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+
+import java.util.Date;
+
+/**
+ * 流程节点扩展实体对象(实例级)。
+ * 业务键是 (processInstanceId, taskKey),即「流程实例 × 节点」,一行一个节点。
+ *
+ * @author chenchuchuan
+ * @date 2026-10-13
+ */
+@Data
+@TableName(value = "zz_flow_task_instance_ext")
+public class FlowTaskInstanceExt {
+
+ /**
+ * 主键Id。
+ */
+ @TableId(value = "id")
+ private Long id;
+
+ /**
+ * 租户Id。
+ */
+ @TableField(value = "tenant_id")
+ private Long tenantId;
+
+ /**
+ * 应用编码。
+ */
+ @TableField(value = "app_code")
+ private String appCode;
+
+ /**
+ * 流程实例Id (→act_hi_taskinst.PROC_INST_ID_)。
+ */
+ @TableField(value = "process_instance_id")
+ private String processInstanceId;
+
+ /**
+ * 流程定义Id (→act_hi_taskinst.PROC_DEF_ID_),取标题模板需用实例自己的版本。
+ */
+ @TableField(value = "process_definition_id")
+ private String processDefinitionId;
+
+ /**
+ * BPMN 节点Id (= taskDefinitionKey,即 act_hi_taskinst.TASK_DEF_KEY_)。
+ */
+ @TableField(value = "task_key")
+ private String taskKey;
+
+ /**
+ * 解析后的节点标题。
+ */
+ @TableField(value = "title")
+ private String title;
+
+ /**
+ * 创建时间。
+ */
+ @TableField(value = "create_time")
+ private Date createTime;
+
+ /**
+ * 创建者Id。
+ */
+ @TableField(value = "create_user_id")
+ private Long createUserId;
+
+ /**
+ * 更新时间。
+ */
+ @TableField(value = "update_time")
+ private Date updateTime;
+
+ /**
+ * 更新者Id。
+ */
+ @TableField(value = "update_user_id")
+ private Long updateUserId;
+}
diff --git a/common/common-flow/src/main/java/apelet/common/flow/model/FlowWorkOrderExt.java b/common/common-flow/src/main/java/apelet/common/flow/model/FlowWorkOrderExt.java
index 8731ccd..4d571d1 100644
--- a/common/common-flow/src/main/java/apelet/common/flow/model/FlowWorkOrderExt.java
+++ b/common/common-flow/src/main/java/apelet/common/flow/model/FlowWorkOrderExt.java
@@ -40,14 +40,6 @@ public class FlowWorkOrderExt {
private String businessData;
/**
- * 节点标题快照,JSON:{"taskKey": "解析后的标题"}。
- * 工单是「一个流程实例一行」,标题则是「实例 × 节点」级,故按 BPMN 节点Id(taskKey) 存成 JSON 承载多节点。
- * 由 FlowUserTaskListener 在任务创建时写入,查询时优先读取,取不到再回退实时解析。
- */
- @TableField(value = "title_json")
- private String titleJson;
-
- /**
* 更新时间。
*/
@TableField(value = "update_time")
diff --git a/common/common-flow/src/main/java/apelet/common/flow/service/FlowApiService.java b/common/common-flow/src/main/java/apelet/common/flow/service/FlowApiService.java
index 6f2bd94..6676520 100644
--- a/common/common-flow/src/main/java/apelet/common/flow/service/FlowApiService.java
+++ b/common/common-flow/src/main/java/apelet/common/flow/service/FlowApiService.java
@@ -4,8 +4,8 @@ import apelet.common.core.object.*;
import apelet.common.flow.model.FlowEntry;
import apelet.common.flow.model.FlowTaskComment;
import apelet.common.flow.model.FlowTaskExt;
+import apelet.common.flow.model.FlowTaskInstanceExt;
import apelet.common.flow.model.FlowWorkOrder;
-import apelet.common.flow.model.FlowWorkOrderExt;
import apelet.common.flow.object.AnalyzedNode;
import apelet.common.flow.object.FlowApiOption;
import apelet.common.flow.vo.FlowTaskVo;
@@ -281,11 +281,12 @@ public interface FlowApiService {
* @param definitionKey 流程定义的标识。
* @param definitionName 流程定义名。
* @param taskName 任务名称。
+ * @param title 节点标题关键字,模糊匹配;为空时不按标题过滤。
* @param pageParam 分页对象。
* @return 用户的任务列表。
*/
- MyPageData getTaskListByUserName(
- String username, String definitionKey, String definitionName, String taskName, MyPageParam pageParam);
+ MyPageData getTaskListByUserName(String username, String definitionKey, String definitionName,
+ String taskName, String title, MyPageParam pageParam);
/**
* 获取用户的任务数量。这其中包括当前用户作为指派人和候选人。
@@ -375,6 +376,7 @@ public interface FlowApiService {
* @param startUser 流程发起用户。
* @param beginDate 流程发起开始时间。
* @param endDate 流程发起结束时间。
+ * @param title 首节点标题关键字,模糊匹配;为空时不按标题过滤。
* @param pageParam 分页对象。
* @param finishedOnly 仅仅返回已经结束的流程。
* @return 分页后的查询列表对象。
@@ -386,6 +388,7 @@ public interface FlowApiService {
String startUser,
String beginDate,
String endDate,
+ String title,
MyPageParam pageParam,
boolean finishedOnly) throws ParseException;
@@ -411,6 +414,7 @@ public interface FlowApiService {
* @param processDefinitionName 流程名。
* @param beginDate 流程发起开始时间。
* @param endDate 流程发起结束时间。
+ * @param title 节点标题关键字,模糊匹配;为空时走原有的 Flowable 查询。
* @param pageParam 分页对象。
* @return 分页后的查询列表对象。
* @throws ParseException 日期参数解析失败。
@@ -419,6 +423,7 @@ public interface FlowApiService {
String processDefinitionName,
String beginDate,
String endDate,
+ String title,
MyPageParam pageParam) throws ParseException;
/**
@@ -661,31 +666,29 @@ public interface FlowApiService {
String resolveTitle(String titleTemplate, String processInstanceId, FlowWorkOrder workOrder, FlowEntry flowEntry);
/**
- * 优先读取已生成的标题快照,取不到再回退 {@link #resolveTitle} 实时解析。
- * 快照在任务创建时由 {@link #buildAndSaveTaskTitle} 写入工单扩展的 titleJson,按 taskKey 定位。
- * 存量数据(改造前已跑的实例)没有快照,会自动走回退分支,因此无需刷数据。
+ * 读取已生成的节点标题。
+ * 标题在任务创建时由 {@link #buildAndSaveTaskTitle} 写入 zz_flow_task_instance_ext,按 (实例, 节点) 定位。
+ * 本方法只读落库值,不做实时解析兜底:没有标题行就返回 null,列表上该行标题为空。
+ * 因此存量数据(改造前已跑的实例)标题会为空,需要回填后才有。
*
- * @param titleTemplate 节点配置的标题模板,回退时使用。
- * @param processInstanceId 流程实例Id。
- * @param workOrder 流程工单对象,为空时直接回退。
- * @param flowEntry 流程对象。
- * @param taskKey BPMN 节点Id,用于在快照中定位。
- * @param workOrderExt 工单扩展对象(调用方批量预载,避免逐行查询),可为空。
- * @return 解析后的标题。
+ * @param workOrder 流程工单对象,为空时返回 null。
+ * @param taskKey BPMN 节点Id,用于定位标题行,为空时返回 null。
+ * @param taskInstanceExt 节点扩展对象(调用方批量预载,避免逐行查询),可为空。
+ * @return 落库的标题;没有标题行返回 null。
*/
- String resolveTitleCached(String titleTemplate, String processInstanceId, FlowWorkOrder workOrder,
- FlowEntry flowEntry, String taskKey, FlowWorkOrderExt workOrderExt);
+ String resolveTitleCached(FlowWorkOrder workOrder, String taskKey, FlowTaskInstanceExt taskInstanceExt);
/**
- * 生成指定节点的标题快照并落库。
- * 取自该节点配置的 title_template,用当时的流程变量与在线表单业务数据解析后写入
- * {@code zz_flow_work_order_ext.title_json}。非在线表单流程、工单为空、标题为空时不写入。
+ * 生成指定节点的标题并落库到 zz_flow_task_instance_ext。
+ * 取自该节点配置的 title_template,用当时的流程变量与在线表单业务数据解析后写入。
+ * 非在线表单流程、工单为空、标题为空时不写入。
* 注意:首节点的 create 事件触发时工单尚未创建(工单在 flowApiService.start 返回后才建),
- * 该场景下 taskKey 传空由本方法自行取当前活动任务节点,供工单创建后补写首节点标题使用。
+ * 该场景下 taskKey 传空,由本方法取该定义的起始节点 taskKey 补写。不能用「当前活动任务」兜底 ——
+ * autoskip 节点在 start() 内部就被 complete 了,那时取到的已经不是首节点。
*
* @param workOrder 流程工单对象。
* @param processDefinitionId 流程定义Id,用于定位节点模板。
- * @param taskKey BPMN 节点Id,为空时取该实例当前的活动任务节点。
+ * @param taskKey BPMN 节点Id,为空时取该定义的起始节点。
* @return 写入的标题;未写入返回null。
*/
String buildAndSaveTaskTitle(FlowWorkOrder workOrder, String processDefinitionId, String taskKey);
diff --git a/common/common-flow/src/main/java/apelet/common/flow/service/FlowTaskInstanceExtService.java b/common/common-flow/src/main/java/apelet/common/flow/service/FlowTaskInstanceExtService.java
new file mode 100644
index 0000000..69ccbfe
--- /dev/null
+++ b/common/common-flow/src/main/java/apelet/common/flow/service/FlowTaskInstanceExtService.java
@@ -0,0 +1,43 @@
+package apelet.common.flow.service;
+
+import apelet.common.core.base.service.IBaseService;
+import apelet.common.flow.model.FlowTaskInstanceExt;
+
+import java.util.Collection;
+import java.util.Map;
+
+/**
+ * 流程节点扩展(实例级)数据操作服务接口。
+ *
+ * @author chenchuchuan
+ * @date 2026-10-13
+ */
+public interface FlowTaskInstanceExtService extends IBaseService {
+
+ /**
+ * 写入指定实例下某节点的标题。
+ * 业务键是 (processInstanceId, taskKey),同一节点重复写入是覆盖语义,不会产生重复行。
+ *
+ * @param processInstanceId 流程实例Id。
+ * @param processDefinitionId 流程定义Id,取标题模板需用实例自己的版本。
+ * @param taskKey BPMN 节点Id(等价于运行时 taskDefinitionKey)。
+ * @param title 解析后的标题。
+ */
+ void saveTaskTitle(String processInstanceId, String processDefinitionId, String taskKey, String title);
+
+ /**
+ * 按实例集合批量获取节点扩展,供列表场景一次预载,避免逐行查询造成 N+1。
+ *
+ * @param processInstanceIds 流程实例Id集合。
+ * @return map,key 为 processInstanceId + ":" + taskKey。
+ */
+ Map getMapByProcessInstanceIds(Collection processInstanceIds);
+
+ /**
+ * 按实例删除全部节点扩展。
+ * 流程实例被物理删除、或草稿工单被删除时调用,避免留下孤儿行、或搜出已删除的单据。
+ *
+ * @param processInstanceId 流程实例Id。
+ */
+ void deleteByProcessInstanceId(String processInstanceId);
+}
diff --git a/common/common-flow/src/main/java/apelet/common/flow/service/FlowTaskTitleBackfillService.java b/common/common-flow/src/main/java/apelet/common/flow/service/FlowTaskTitleBackfillService.java
new file mode 100644
index 0000000..b3f736e
--- /dev/null
+++ b/common/common-flow/src/main/java/apelet/common/flow/service/FlowTaskTitleBackfillService.java
@@ -0,0 +1,25 @@
+package apelet.common.flow.service;
+
+import apelet.common.flow.vo.FlowTaskTitleBackfillVo;
+
+/**
+ * 节点标题回填服务。
+ * 用于把改造前就已存在的流程实例的节点标题补齐到 zz_flow_task_instance_ext。
+ * 改造前标题是查询时实时算的、从不落库,所以存量实例没有标题数据。
+ *
+ * @author chenchuchuan
+ * @date 2026-10-14
+ */
+public interface FlowTaskTitleBackfillService {
+
+ /**
+ * 回填一批工单的节点标题。
+ * 按工单Id游标分批,幂等(已有标题行的 (实例, 节点) 直接跳过),可重复执行、可中断续跑。
+ *
+ * @param cursorWorkOrderId 游标,取工单Id大于该值的记录;传 null 表示从头开始。
+ * @param batchSize 单批工单数,为空或非法时取默认值。
+ * @param dryRun 为 true 时只统计不写库。
+ * @return 本批执行结果,其中 nextCursor 为下一批游标,finished 表示是否已跑完。
+ */
+ FlowTaskTitleBackfillVo backfill(Long cursorWorkOrderId, Integer batchSize, Boolean dryRun);
+}
diff --git a/common/common-flow/src/main/java/apelet/common/flow/service/FlowWorkOrderService.java b/common/common-flow/src/main/java/apelet/common/flow/service/FlowWorkOrderService.java
index 7775f6d..b8d2f3e 100644
--- a/common/common-flow/src/main/java/apelet/common/flow/service/FlowWorkOrderService.java
+++ b/common/common-flow/src/main/java/apelet/common/flow/service/FlowWorkOrderService.java
@@ -160,15 +160,15 @@ public interface FlowWorkOrderService extends IBaseService
List getFlowWorkOrderExtByWorkOrderIds(Set workOrderIds);
/**
- * 写入指定工单下某节点的标题快照。
- * titleJson 是「实例 × 节点」的映射,写入时按 taskKey 合并(读-改-写),
- * 保留其他节点已写入的条目,不能整串覆盖。扩展行不存在时自动创建。
+ * 按工单Id游标分批取工单,供离线/批量任务遍历使用。
+ * 与 {@link #getFlowWorkOrderList} 不同,本方法不做租户过滤、不依赖请求上下文,
+ * 否则在无登录态的维护接口里会因 TokenData 为空而 NPE。
*
- * @param workOrderId 工单Id。
- * @param taskKey BPMN 节点Id(等价于运行时 taskDefinitionKey)。
- * @param title 解析后的标题。
+ * @param cursorWorkOrderId 游标,取工单Id大于该值的记录;传 null 表示从头开始。
+ * @param limit 单批数量。
+ * @return 工单列表,按工单Id升序。
*/
- void saveTaskTitle(Long workOrderId, String taskKey, String title);
+ List getListByWorkOrderIdAfter(Long cursorWorkOrderId, int limit);
/**
* 移除草稿工单,同时停止已经启动的流程实例。
diff --git a/common/common-flow/src/main/java/apelet/common/flow/service/impl/FlowApiServiceImpl.java b/common/common-flow/src/main/java/apelet/common/flow/service/impl/FlowApiServiceImpl.java
index e55235a..8809994 100644
--- a/common/common-flow/src/main/java/apelet/common/flow/service/impl/FlowApiServiceImpl.java
+++ b/common/common-flow/src/main/java/apelet/common/flow/service/impl/FlowApiServiceImpl.java
@@ -23,6 +23,7 @@ import apelet.common.flow.util.BaseFlowIdentityExtHelper;
import apelet.common.flow.util.FlowCustomExtFactory;
import apelet.common.flow.vo.FlowTaskVo;
import apelet.common.flow.vo.FlowUserInfoVo;
+import apelet.common.flow.vo.TaskInfoVo;
import cn.hutool.core.collection.CollUtil;
import cn.hutool.core.convert.Convert;
import cn.hutool.core.lang.Assert;
@@ -45,6 +46,7 @@ import org.flowable.common.engine.api.delegate.Expression;
import org.flowable.common.engine.impl.de.odysseus.el.ExpressionFactoryImpl;
import org.flowable.common.engine.impl.de.odysseus.el.util.SimpleContext;
import org.flowable.common.engine.impl.identity.Authentication;
+import org.flowable.common.engine.api.query.NativeQuery;
import org.flowable.common.engine.impl.javax.el.ExpressionFactory;
import org.flowable.common.engine.impl.javax.el.ValueExpression;
import org.flowable.engine.*;
@@ -123,6 +125,8 @@ public class FlowApiServiceImpl implements FlowApiService {
private FlowVariableLogService flowVariableLogService;
@Autowired
private FlowTaskTimeoutJobService flowTaskTimeoutJobService;
+ @Autowired
+ private FlowTaskInstanceExtService flowTaskInstanceExtService;
@Transactional(rollbackFor = Exception.class)
@Override
@@ -690,8 +694,8 @@ public class FlowApiServiceImpl implements FlowApiService {
}
@Override
- public MyPageData getTaskListByUserName(
- String username, String definitionKey, String definitionName, String taskName, MyPageParam pageParam) {
+ public MyPageData getTaskListByUserName(String username, String definitionKey, String definitionName,
+ String taskName, String title, MyPageParam pageParam) {
TaskQuery query = this.createQuery();
if (StrUtil.isNotBlank(definitionKey)) {
query.processDefinitionKey(definitionKey);
@@ -705,10 +709,13 @@ public class FlowApiServiceImpl implements FlowApiService {
this.buildCandidateCondition(query, username);
query.orderByTaskCreateTime().desc();
Map multiInstanceTaskMap = new HashMap<>();
- long totalCount = this.countOwnedTaskList(query, username, multiInstanceTaskMap);
+ // 标题过滤只能在 Java 侧做:标题存在我们自己的表里,Flowable 的查询构造器表达不了;
+ // 而本列表的分页本来就是「批扫 + 逐条过滤」的(会签实例对候选者全体可见),跟着同一个后置过滤加最自然。
+ TitleFilterContext titleFilter = new TitleFilterContext(title);
+ long totalCount = this.countOwnedTaskList(query, username, multiInstanceTaskMap, titleFilter);
int firstResult = (pageParam.getPageNum() - 1) * pageParam.getPageSize();
List taskList = this.listOwnedTaskPage(
- query, username, firstResult, pageParam.getPageSize(), multiInstanceTaskMap);
+ query, username, firstResult, pageParam.getPageSize(), multiInstanceTaskMap, titleFilter);
return new MyPageData<>(taskList, totalCount);
}
@@ -716,7 +723,8 @@ public class FlowApiServiceImpl implements FlowApiService {
public long getTaskCountByUserName(String username) {
TaskQuery query = this.createQuery();
this.buildCandidateCondition(query, username);
- return this.countOwnedTaskList(query, username, new HashMap<>());
+ // 待办数量是角标,不按标题过滤
+ return this.countOwnedTaskList(query, username, new HashMap<>(), new TitleFilterContext(null));
}
/**
@@ -724,7 +732,8 @@ public class FlowApiServiceImpl implements FlowApiService {
* 多实例会签节点在流程图中将全部会签人同时配置为候选者,导致每个实例对所有人可见。
* 这里只保留会签实例归属人自己的那一条,其他会签人的实例不参与统计。
*/
- private long countOwnedTaskList(TaskQuery query, String username, Map multiInstanceTaskMap) {
+ private long countOwnedTaskList(TaskQuery query, String username, Map multiInstanceTaskMap,
+ TitleFilterContext titleFilter) {
long totalCount = 0;
int cursor = 0;
while (true) {
@@ -733,8 +742,10 @@ public class FlowApiServiceImpl implements FlowApiService {
break;
}
cursor += batch.size();
+ this.loadTitles(batch, titleFilter);
for (Task task : batch) {
- if (this.isTaskOwnedByUser(task, username, multiInstanceTaskMap)) {
+ if (this.isTaskOwnedByUser(task, username, multiInstanceTaskMap)
+ && this.isTitleMatched(task, titleFilter)) {
totalCount++;
}
}
@@ -747,7 +758,8 @@ public class FlowApiServiceImpl implements FlowApiService {
* 多实例会签节点的实例对候选者全体可见,分页时跳过不属于当前用户的其他会签实例。
*/
private List listOwnedTaskPage(
- TaskQuery query, String username, int firstResult, int pageSize, Map multiInstanceTaskMap) {
+ TaskQuery query, String username, int firstResult, int pageSize, Map multiInstanceTaskMap,
+ TitleFilterContext titleFilter) {
List resultList = new LinkedList<>();
int skipCount = firstResult;
int cursor = 0;
@@ -757,8 +769,10 @@ public class FlowApiServiceImpl implements FlowApiService {
break;
}
cursor += batch.size();
+ this.loadTitles(batch, titleFilter);
for (Task task : batch) {
- if (!this.isTaskOwnedByUser(task, username, multiInstanceTaskMap)) {
+ if (!this.isTaskOwnedByUser(task, username, multiInstanceTaskMap)
+ || !this.isTitleMatched(task, titleFilter)) {
continue;
}
if (skipCount > 0) {
@@ -775,6 +789,83 @@ public class FlowApiServiceImpl implements FlowApiService {
}
/**
+ * 按批懒加载标题:只查本批里还没加载过的实例,避免逐条查库。
+ *
+ * @param batch 本批任务。
+ * @param titleFilter 标题过滤上下文,关键字为空时直接跳过、不做任何查询。
+ */
+ private void loadTitles(Collection batch, TitleFilterContext titleFilter) {
+ if (!titleFilter.enabled()) {
+ return;
+ }
+ Set toLoadIdSet = new HashSet<>();
+ for (Task task : batch) {
+ String processInstanceId = task.getProcessInstanceId();
+ if (StrUtil.isNotBlank(processInstanceId) && titleFilter.loadedInstanceIdSet.add(processInstanceId)) {
+ toLoadIdSet.add(processInstanceId);
+ }
+ }
+ if (CollUtil.isNotEmpty(toLoadIdSet)) {
+ titleFilter.titleMap.putAll(flowTaskInstanceExtService.getMapByProcessInstanceIds(toLoadIdSet));
+ }
+ }
+
+ /**
+ * 判断任务标题是否命中关键字。
+ * 标题只认 zz_flow_task_instance_ext 的落库值:没有标题行的一律不匹配,与列表展示口径一致。
+ *
+ * @param task 任务对象。
+ * @param titleFilter 标题过滤上下文,关键字为空时视为命中。
+ * @return 是否命中。
+ */
+ private boolean isTitleMatched(Task task, TitleFilterContext titleFilter) {
+ if (!titleFilter.enabled()) {
+ return true;
+ }
+ FlowTaskInstanceExt taskInstanceExt =
+ titleFilter.titleMap.get(task.getProcessInstanceId() + ":" + task.getTaskDefinitionKey());
+ return taskInstanceExt != null && StrUtil.isNotBlank(taskInstanceExt.getTitle())
+ && StrUtil.contains(taskInstanceExt.getTitle(), titleFilter.keyword);
+ }
+
+ /**
+ * 待办列表标题过滤的上下文。
+ * 标题存在 zz_flow_task_instance_ext 上,Flowable 的查询构造器表达不了这个条件,只能在 Java 侧过滤;
+ * 而过滤时又要按实例懒加载标题、避免逐条查库。关键字、已加载实例集合、标题缓存这三样永远成组出现,
+ * 抽成一个对象,免得在几个方法之间来回传六个参数。
+ */
+ private static class TitleFilterContext {
+
+ /**
+ * 标题关键字;为空表示不按标题过滤。
+ */
+ private final String keyword;
+
+ /**
+ * 已经加载过标题的实例Id,避免同一实例重复查库。
+ */
+ private final Set loadedInstanceIdSet = new HashSet<>();
+
+ /**
+ * 标题缓存,key 为 processInstanceId + ":" + taskKey。
+ */
+ private final Map titleMap = new HashMap<>();
+
+ private TitleFilterContext(String keyword) {
+ this.keyword = keyword;
+ }
+
+ /**
+ * 是否按标题过滤。关键字为空时整个上下文不产生任何查询。
+ *
+ * @return 需要按标题过滤返回 true。
+ */
+ private boolean enabled() {
+ return StrUtil.isNotBlank(keyword);
+ }
+ }
+
+ /**
* 判断任务是否属于当前用户。
* 多实例会签实例的元素变量 assignee 即该实例真正的归属人。
*/
@@ -906,11 +997,9 @@ public class FlowApiServiceImpl implements FlowApiService {
flowWorkOrderService.getInList("processInstanceId", procInstanceIdSet);
Map workOrderMap =
workOrderList.stream().collect(Collectors.toMap(FlowWorkOrder::getProcessInstanceId, c -> c));
- // 标题快照按工单批量预载(一次 IN 查询覆盖整页),避免逐行查扩展对象造成 N+1。
- Map workOrderExtMap = flowWorkOrderService
- .getFlowWorkOrderExtByWorkOrderIds(workOrderList.stream()
- .map(FlowWorkOrder::getWorkOrderId).filter(Objects::nonNull).collect(Collectors.toSet()))
- .stream().collect(Collectors.toMap(FlowWorkOrderExt::getWorkOrderId, c -> c, (k1, k2) -> k1));
+ // 节点标题按实例批量预载(一次 IN 查询覆盖整页),避免逐行查询造成 N+1。
+ Map taskInstanceExtMap =
+ flowTaskInstanceExtService.getMapByProcessInstanceIds(procInstanceIdSet);
List allFlowTaskExtList = flowTaskExtService.getByProcessDefinitionId(processDefinitionIdSet);
Map flowTaskExtMap = allFlowTaskExtList.stream().collect(Collectors.toMap(t -> t.getProcessDefinitionId() + ":" + t.getTaskId(), t -> t, (k1, k2) -> k1));
Map multiInstanceTaskMap = new HashMap<>();
@@ -943,9 +1032,8 @@ public class FlowApiServiceImpl implements FlowApiService {
FlowTaskExt flowTaskExt = flowTaskExtMap.get(task.getProcessDefinitionId() + ":" + task.getTaskDefinitionKey());
FlowEntry flowEntry = flowEntryMap.get(flowEntryPublishMap.get(task.getProcessDefinitionId()).getEntryId());
if (flowTaskExt != null && flowEntry != null) {
- flowTaskVo.setTitle(this.resolveTitleCached(flowTaskExt.getTitleTemplate(),
- task.getProcessInstanceId(), flowWorkOrder, flowEntry, task.getTaskDefinitionKey(),
- flowWorkOrder == null ? null : workOrderExtMap.get(flowWorkOrder.getWorkOrderId())));
+ flowTaskVo.setTitle(this.resolveTitleCached(flowWorkOrder, task.getTaskDefinitionKey(),
+ taskInstanceExtMap.get(task.getProcessInstanceId() + ":" + task.getTaskDefinitionKey())));
}
flowTaskVoList.add(flowTaskVo);
}
@@ -1022,8 +1110,16 @@ public class FlowApiServiceImpl implements FlowApiService {
String startUser,
String beginDate,
String endDate,
+ String title,
MyPageParam pageParam,
boolean finishedOnly) throws ParseException {
+ if (StrUtil.isNotBlank(title)) {
+ // 标题存在 zz_flow_task_instance_ext 上,Flowable 的查询构造器表达不了这个条件;
+ // 而本接口的分页与总数全由 SQL 决定,Java 侧再过滤会把分页搞坏,所以改走原生 join。
+ return this.getHistoricProcessInstanceListByTitle(
+ processDefinitionKey, processDefinitionName, startUser, beginDate, endDate, title,
+ pageParam, finishedOnly);
+ }
HistoricProcessInstanceQuery query = historyService.createHistoricProcessInstanceQuery();
if (StrUtil.isNotBlank(processDefinitionKey)) {
query.processDefinitionKey(processDefinitionKey);
@@ -1062,13 +1158,87 @@ public class FlowApiServiceImpl implements FlowApiService {
return new MyPageData<>(instanceList, totalCount);
}
+ /**
+ * 按首节点标题关键字查询历史流程实例,走 Flowable 的原生 join 查询。
+ * 过滤条件逐个复刻自 {@code HistoricProcessInstanceQuery}(源码 HistoricProcessInstance.xml),
+ * 漏一个就是 bug,且只在带关键字时复现:
+ * processDefinitionKey→DEF.KEY_;processDefinitionName→DEF.NAME_;startedBy→RES.START_USER_ID_;
+ * startedAfter/startedBefore→RES.START_TIME_ >= / <= (注意是闭区间);
+ * finished→RES.END_TIME_ is not null;tenantId/withoutTenantId→RES.TENANT_ID_ 等值、或空串/NULL;
+ * orderByProcessInstanceStartTime().desc()→order by RES.START_TIME_ desc。
+ * 标题口径与列表展示保持一致 —— 只取首节点的标题。首节点 taskKey 存在发布版本的
+ * {@code zz_flow_entry_publish.init_task_info} 里,随流程定义而变,所以要在 SQL 里用 JSON 函数解析。
+ * init_task_info 是 TEXT,可能不是合法 JSON,先用 CASE + JSON_VALID 挡一道,避免脏数据让整个查询报错。
+ * 表名 ACT_HI_PROCINST / ACT_RE_PROCDEF 必须写死 —— Flowable 原生查询不支持 ${prefix} 占位符。
+ * 本工程未配置 flowable 的 database-table-prefix,将来若配置,此处表名需同步修改;
+ * Flowable 升级时也需复查列名与语义。
+ */
+ private MyPageData getHistoricProcessInstanceListByTitle(
+ String processDefinitionKey, String processDefinitionName, String startUser,
+ String beginDate, String endDate, String title, MyPageParam pageParam,
+ boolean finishedOnly) throws ParseException {
+ Map params = new HashMap<>(8);
+ params.put("title", "%" + this.escapeLikeKeyword(title) + "%");
+ StringBuilder where = new StringBuilder(256);
+ where.append(" WHERE TT.TITLE LIKE #{title}");
+ if (StrUtil.isNotBlank(processDefinitionKey)) {
+ where.append(" AND DEF.KEY_ = #{processDefinitionKey}");
+ params.put("processDefinitionKey", processDefinitionKey);
+ }
+ if (StrUtil.isNotBlank(processDefinitionName)) {
+ where.append(" AND DEF.NAME_ = #{processDefinitionName}");
+ params.put("processDefinitionName", processDefinitionName);
+ }
+ if (StrUtil.isNotBlank(startUser)) {
+ where.append(" AND RES.START_USER_ID_ = #{startUser}");
+ params.put("startUser", startUser);
+ }
+ if (StrUtil.isNotBlank(beginDate)) {
+ SimpleDateFormat sdf = new SimpleDateFormat(MyDateUtil.COMMON_SHORT_DATETIME_FORMAT);
+ where.append(" AND RES.START_TIME_ >= #{beginDate}");
+ params.put("beginDate", sdf.parse(beginDate));
+ }
+ if (StrUtil.isNotBlank(endDate)) {
+ SimpleDateFormat sdf = new SimpleDateFormat(MyDateUtil.COMMON_SHORT_DATETIME_FORMAT);
+ where.append(" AND RES.START_TIME_ <= #{endDate}");
+ params.put("endDate", sdf.parse(endDate));
+ }
+ this.appendTenantCondition(where, params);
+ if (finishedOnly) {
+ where.append(" AND RES.END_TIME_ is not null");
+ }
+ // RES.* 之外必须带上 DEF 的四个别名列:原生查询复用的是 historicProcessInstanceResultMap,
+ // 列表回去要用 getProcessDefinitionKey()/getName(),漏了这几列这两项就是空的。
+ String fromJoin = " FROM ACT_HI_PROCINST RES"
+ + " LEFT OUTER JOIN ACT_RE_PROCDEF DEF ON RES.PROC_DEF_ID_ = DEF.ID_"
+ + " INNER JOIN zz_flow_entry_publish FP ON FP.process_definition_id = RES.PROC_DEF_ID_"
+ + " INNER JOIN zz_flow_task_instance_ext TT ON TT.PROCESS_INSTANCE_ID = RES.ID_"
+ + " AND TT.TASK_KEY = CASE WHEN JSON_VALID(FP.init_task_info)"
+ + " THEN JSON_UNQUOTE(JSON_EXTRACT(FP.init_task_info, '$.taskKey')) ELSE NULL END";
+ return this.executeNativeTitleQuery(
+ historyService.createNativeHistoricProcessInstanceQuery().sql(
+ "SELECT RES.*, DEF.KEY_ AS PROC_DEF_KEY_, DEF.NAME_ AS PROC_DEF_NAME_,"
+ + " DEF.VERSION_ AS PROC_DEF_VERSION_, DEF.DEPLOYMENT_ID_ AS DEPLOYMENT_ID_"
+ + fromJoin + where + " ORDER BY RES.START_TIME_ DESC"),
+ historyService.createNativeHistoricProcessInstanceQuery().sql("SELECT COUNT(*)" + fromJoin + where),
+ params, pageParam);
+ }
+
@Override
public MyPageData getHistoricTaskInstanceFinishedList(
String processDefinitionName,
String beginDate,
String endDate,
+ String title,
MyPageParam pageParam) throws ParseException {
String loginName = TokenData.takeFromRequest().getLoginName();
+ if (StrUtil.isNotBlank(title)) {
+ // 标题存在 zz_flow_task_instance_ext 上,Flowable 的查询构造器表达不了这个条件;
+ // 而本接口的分页/排序/总数由 count()+listPage() 决定,Java 侧再过滤会让 totalCount 与页内容错乱,
+ // 所以带关键字时改走原生 join 查询,把过滤/排序/分页/count 全部下推到 SQL。
+ return this.getHistoricTaskInstanceFinishedListByTitle(
+ processDefinitionName, beginDate, endDate, title, loginName, pageParam);
+ }
HistoricTaskInstanceQuery query = historyService.createHistoricTaskInstanceQuery()
.taskAssignee(loginName)
.finished();
@@ -1100,6 +1270,109 @@ public class FlowApiServiceImpl implements FlowApiService {
return new MyPageData<>(instanceList, totalCount);
}
+ /**
+ * 按节点标题关键字查询当前用户已办理的任务,走 Flowable 的原生 join 查询。
+ * 过滤条件逐个复刻自 {@code HistoricTaskInstanceQuery}(源码 HistoricTaskInstance.xml),漏一个就是 bug,
+ * 且只在带关键字时复现:
+ * taskAssignee→ASSIGNEE_;finished→END_TIME_ is not null;taskCompletedAfter/Before→END_TIME_ > / <;
+ * processDefinitionName→ACT_RE_PROCDEF 的 exists 子查询;taskTenantId/taskWithoutTenantId→TENANT_ID_ 等值、
+ * 或空串/NULL;orderByHistoricTaskInstanceEndTime().desc()→order by END_TIME_ desc。
+ * 表名 ACT_HI_TASKINST 必须写死 —— Flowable 的原生查询不支持 ${prefix} 占位符(实测会被渲染成字面量)。
+ * 本工程未配置 flowable 的 database-table-prefix,将来若配置,此处表名需同步修改;
+ * Flowable 升级时也需复查上述列名与语义。
+ * 另:join 条件命中 zz_flow_task_instance_ext 的唯一键 (process_instance_id, task_key),走 eq_ref;
+ * 但 LIKE '%kw%' 用不上索引,且选择性差,MySQL 有可能选错驱动表导致匹配集全量 filesort。
+ * 上线前用 EXPLAIN 确认驱动表是 ACT_HI_TASKINST,必要时把 INNER JOIN 改为 STRAIGHT_JOIN。
+ */
+ private MyPageData getHistoricTaskInstanceFinishedListByTitle(
+ String processDefinitionName, String beginDate, String endDate, String title,
+ String loginName, MyPageParam pageParam) throws ParseException {
+ Map params = new HashMap<>(8);
+ params.put("assignee", loginName);
+ params.put("title", "%" + this.escapeLikeKeyword(title) + "%");
+ StringBuilder where = new StringBuilder(256);
+ where.append(" WHERE RES.ASSIGNEE_ = #{assignee} AND RES.END_TIME_ is not null AND TT.TITLE LIKE #{title}");
+ if (StrUtil.isNotBlank(beginDate)) {
+ SimpleDateFormat sdf = new SimpleDateFormat(MyDateUtil.COMMON_SHORT_DATETIME_FORMAT);
+ where.append(" AND RES.END_TIME_ > #{beginDate}");
+ params.put("beginDate", sdf.parse(beginDate));
+ }
+ if (StrUtil.isNotBlank(endDate)) {
+ SimpleDateFormat sdf = new SimpleDateFormat(MyDateUtil.COMMON_SHORT_DATETIME_FORMAT);
+ where.append(" AND RES.END_TIME_ < #{endDate}");
+ params.put("endDate", sdf.parse(endDate));
+ }
+ if (StrUtil.isNotBlank(processDefinitionName)) {
+ where.append(" AND exists (select 1 from ACT_RE_PROCDEF D")
+ .append(" where RES.PROC_DEF_ID_ = D.ID_ and D.NAME_ = #{processDefinitionName})");
+ params.put("processDefinitionName", processDefinitionName);
+ }
+ this.appendTenantCondition(where, params);
+ String fromJoin = " FROM ACT_HI_TASKINST RES INNER JOIN zz_flow_task_instance_ext TT"
+ + " ON TT.PROCESS_INSTANCE_ID = RES.PROC_INST_ID_ AND TT.TASK_KEY = RES.TASK_DEF_KEY_";
+ return this.executeNativeTitleQuery(
+ historyService.createNativeHistoricTaskInstanceQuery()
+ .sql("SELECT RES.*" + fromJoin + where + " ORDER BY RES.END_TIME_ DESC"),
+ historyService.createNativeHistoricTaskInstanceQuery().sql("SELECT COUNT(*)" + fromJoin + where),
+ params, pageParam);
+ }
+
+ /**
+ * 转义 LIKE 的通配符,避免用户输入里的 % 和 _ 被当成通配符。
+ * 反斜杠是 MySQL 的默认转义符,自身要先转义。
+ *
+ * @param keyword 用户输入的关键字。
+ * @return 转义后的关键字。
+ */
+ private String escapeLikeKeyword(String keyword) {
+ return keyword.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_");
+ }
+
+ /**
+ * 执行「list + count 两条同 FROM/WHERE 的原生查询」,两处原生标题查询共用。
+ * count 与 list 必须共用同一套 FROM/WHERE、且 count 不能带 ORDER BY,否则 totalCount 与页内容会对不上。
+ *
+ * @param listQuery 已设置好 sql 的 list 查询。
+ * @param countQuery 已设置好 sql 的 count 查询。
+ * @param params 绑参,会同时应用到两条查询。
+ * @param pageParam 分页对象。
+ * @param 查询返回的行类型。
+ * @return 分页结果。
+ */
+ private MyPageData executeNativeTitleQuery(
+ NativeQuery, R> listQuery, NativeQuery, R> countQuery,
+ Map params, MyPageParam pageParam) {
+ for (Map.Entry entry : params.entrySet()) {
+ listQuery.parameter(entry.getKey(), entry.getValue());
+ countQuery.parameter(entry.getKey(), entry.getValue());
+ }
+ long totalCount = countQuery.count();
+ int firstResult = (pageParam.getPageNum() - 1) * pageParam.getPageSize();
+ return new MyPageData<>(listQuery.listPage(firstResult, pageParam.getPageSize()), totalCount);
+ }
+
+ /**
+ * 追加租户过滤条件,两处原生标题查询共用。
+ * 语义与 Flowable 的 taskTenantId / taskWithoutTenantId(历史流程侧是 processInstanceTenantId /
+ * processInstanceWithoutTenantId)保持一致:有租户Id走等值;既没有租户Id也没有 appCode 时按"无租户"处理
+ * (空串或 NULL);否则把 appCode 当租户。
+ *
+ * @param where 正在拼装的 WHERE 片段。
+ * @param params 绑参容器,tenantId 参数由本方法写入。
+ */
+ private void appendTenantCondition(StringBuilder where, Map params) {
+ TokenData tokenData = TokenData.takeFromRequest();
+ if (tokenData.getTenantId() != null) {
+ where.append(" AND RES.TENANT_ID_ = #{tenantId}");
+ params.put("tenantId", tokenData.getTenantId().toString());
+ } else if (StrUtil.isBlank(tokenData.getAppCode())) {
+ where.append(" AND (RES.TENANT_ID_ = '' or RES.TENANT_ID_ is null)");
+ } else {
+ where.append(" AND RES.TENANT_ID_ = #{tenantId}");
+ params.put("tenantId", tokenData.getAppCode());
+ }
+ }
+
@Override
public List getHistoricActivityInstanceList(String processInstanceId) {
return historyService.createHistoricActivityInstanceQuery().processInstanceId(processInstanceId).list();
@@ -1183,6 +1456,9 @@ public class FlowApiServiceImpl implements FlowApiService {
@Override
public void deleteProcessInstance(String processInstanceId) {
historyService.deleteHistoricProcessInstance(processInstanceId);
+ // deleteHistoricProcessInstance 会级联物理删除该实例的全部 act_hi_taskinst,
+ // 节点扩展里的标题行不跟着删就成了永远无用的孤儿行。
+ flowTaskInstanceExtService.deleteByProcessInstanceId(processInstanceId);
flowMessageService.removeByProcessInstanceId(processInstanceId);
FlowWorkOrder workOrder = flowWorkOrderService.getFlowWorkOrderByProcessInstanceId(processInstanceId);
if (workOrder == null) {
@@ -2695,22 +2971,14 @@ public class FlowApiServiceImpl implements FlowApiService {
}
@Override
- public String resolveTitleCached(String titleTemplate, String processInstanceId, FlowWorkOrder workOrder,
- FlowEntry flowEntry, String taskKey, FlowWorkOrderExt workOrderExt) {
- if (workOrder != null && workOrderExt != null && StrUtil.isNotBlank(taskKey)
- && StrUtil.isNotBlank(workOrderExt.getTitleJson())) {
- try {
- String cached = JSON.parseObject(workOrderExt.getTitleJson()).getString(taskKey);
- if (StrUtil.isNotBlank(cached)) {
- return cached;
- }
- } catch (Exception e) {
- // 快照解析失败不能影响列表整体,降级为实时解析。
- log.warn("标题快照解析失败,降级为实时解析。workOrderId [{}], taskKey [{}]",
- workOrder.getWorkOrderId(), taskKey, e);
- }
+ public String resolveTitleCached(FlowWorkOrder workOrder, String taskKey, FlowTaskInstanceExt taskInstanceExt) {
+ // 标题只以 zz_flow_task_instance_ext 的落库值为准,不再回退到按模板实时解析。
+ // 这样"这个节点到底有没有落库"在列表上一眼可见,排查/联调时不会被实时兜底掩盖。
+ if (workOrder == null || taskInstanceExt == null || StrUtil.isBlank(taskKey)
+ || StrUtil.isBlank(taskInstanceExt.getTitle())) {
+ return null;
}
- return this.resolveTitle(titleTemplate, processInstanceId, workOrder, flowEntry);
+ return taskInstanceExt.getTitle();
}
@Override
@@ -2720,13 +2988,13 @@ public class FlowApiServiceImpl implements FlowApiService {
}
String nodeKey = taskKey;
if (StrUtil.isBlank(nodeKey)) {
- // 首节点补写场景:create 事件触发时工单还没创建,走到这里工单已建,取当前活动任务节点即可。
- Task activeTask = taskService.createTaskQuery()
- .processInstanceId(workOrder.getProcessInstanceId()).active().singleResult();
- if (activeTask == null) {
+ // 首节点补写场景:create 事件触发时工单还没创建(工单在 flowApiService.start 返回之后才建),
+ // 走到这里流程可能已经推进过了(autoskip 节点在 start() 内部就被 complete),
+ // 所以不能取「当前活动任务」,要取该定义的起始节点,与历史流程列表的口径保持一致。
+ nodeKey = this.getInitTaskKey(processDefinitionId);
+ if (StrUtil.isBlank(nodeKey)) {
return null;
}
- nodeKey = activeTask.getTaskDefinitionKey();
}
FlowEntry flowEntry = StrUtil.isBlank(workOrder.getProcessDefinitionKey()) ? null
: flowEntryService.getFlowEntryFromCache(workOrder.getProcessDefinitionKey());
@@ -2740,11 +3008,38 @@ public class FlowApiServiceImpl implements FlowApiService {
if (StrUtil.isBlank(title)) {
return null;
}
- flowWorkOrderService.saveTaskTitle(workOrder.getWorkOrderId(), nodeKey, title);
+ flowTaskInstanceExtService.saveTaskTitle(workOrder.getProcessInstanceId(), processDefinitionId, nodeKey, title);
return title;
}
/**
+ * 取指定流程定义的起始节点 taskKey。
+ * 来源是该发布版本的 initTaskInfo,与历史流程列表(FlowOperationController#listHistoricProcessInstance)
+ * 取首节点标题模板的口径保持一致。
+ *
+ * @param processDefinitionId 流程定义Id。
+ * @return 起始节点 taskKey,取不到返回null。
+ */
+ private String getInitTaskKey(String processDefinitionId) {
+ if (StrUtil.isBlank(processDefinitionId)) {
+ return null;
+ }
+ List publishList =
+ flowEntryService.getFlowEntryPublishList(CollUtil.newHashSet(processDefinitionId));
+ for (FlowEntryPublish publish : publishList) {
+ if (!StrUtil.equals(publish.getProcessDefinitionId(), processDefinitionId)
+ || StrUtil.isBlank(publish.getInitTaskInfo())) {
+ continue;
+ }
+ TaskInfoVo initTaskInfo = JSON.parseObject(publish.getInitTaskInfo(), TaskInfoVo.class);
+ if (initTaskInfo != null && StrUtil.isNotBlank(initTaskInfo.getTaskKey())) {
+ return initTaskInfo.getTaskKey();
+ }
+ }
+ return null;
+ }
+
+ /**
* 首节点生成标题时还没发生过任何任务完成,${submitUser} 尚未写入(它由 completeTask 设置),
* 直接解析会留下字面量。首节点的"提交人"语义上就是发起人,故此时把模板里的 ${submitUser} 换成 ${initiator}。
* 第 2 个节点起上一个节点的 complete 已写好 submitUser,原样返回。
diff --git a/common/common-flow/src/main/java/apelet/common/flow/service/impl/FlowTaskInstanceExtServiceImpl.java b/common/common-flow/src/main/java/apelet/common/flow/service/impl/FlowTaskInstanceExtServiceImpl.java
new file mode 100644
index 0000000..224269a
--- /dev/null
+++ b/common/common-flow/src/main/java/apelet/common/flow/service/impl/FlowTaskInstanceExtServiceImpl.java
@@ -0,0 +1,139 @@
+package apelet.common.flow.service.impl;
+
+import apelet.common.core.annotation.MyDataSourceResolver;
+import apelet.common.core.base.dao.BaseDaoMapper;
+import apelet.common.core.base.service.BaseService;
+import apelet.common.core.constant.ApplicationConstant;
+import apelet.common.core.object.TokenData;
+import apelet.common.core.util.DefaultDataSourceResolver;
+import apelet.common.flow.dao.FlowTaskInstanceExtMapper;
+import apelet.common.flow.model.FlowTaskInstanceExt;
+import apelet.common.flow.service.FlowTaskInstanceExtService;
+import apelet.common.sequence.wrapper.IdGeneratorWrapper;
+import cn.hutool.core.collection.CollUtil;
+import cn.hutool.core.util.StrUtil;
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.util.Collection;
+import java.util.Date;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.stream.Collectors;
+
+/**
+ * 流程节点扩展(实例级)数据操作服务类。
+ *
+ * @author chenchuchuan
+ * @date 2026-10-13
+ */
+@MyDataSourceResolver(
+ resolver = DefaultDataSourceResolver.class,
+ intArg = ApplicationConstant.COMMON_FLOW_AND_ONLINE_DATASOURCE_TYPE)
+@Service("flowTaskInstanceExtService")
+public class FlowTaskInstanceExtServiceImpl extends BaseService
+ implements FlowTaskInstanceExtService {
+
+ /**
+ * title 列的长度上限(varchar(1024)),超出会被数据库拒绝,写入前先截断。
+ */
+ private static final int TITLE_MAX_LENGTH = 1024;
+
+ /**
+ * 截断时补的省略号。
+ */
+ private static final String TITLE_ELLIPSIS = "...";
+
+ @Autowired
+ private FlowTaskInstanceExtMapper flowTaskInstanceExtMapper;
+ @Autowired
+ private IdGeneratorWrapper idGenerator;
+
+ /**
+ * 返回当前Service的主表Mapper对象。
+ *
+ * @return 主表Mapper对象。
+ */
+ @Override
+ protected BaseDaoMapper mapper() {
+ return flowTaskInstanceExtMapper;
+ }
+
+ @Transactional(rollbackFor = Exception.class)
+ @Override
+ public void saveTaskTitle(String processInstanceId, String processDefinitionId, String taskKey, String title) {
+ if (StrUtil.isBlank(processInstanceId) || StrUtil.isBlank(taskKey) || StrUtil.isBlank(title)) {
+ return;
+ }
+ FlowTaskInstanceExt entity = new FlowTaskInstanceExt();
+ entity.setId(idGenerator.nextLongId());
+ entity.setProcessInstanceId(processInstanceId);
+ entity.setProcessDefinitionId(processDefinitionId);
+ entity.setTaskKey(taskKey);
+ entity.setTitle(this.truncateTitle(title));
+ Date now = new Date();
+ entity.setCreateTime(now);
+ entity.setUpdateTime(now);
+ entity.setCreateUserId(this.getOperatorId());
+ entity.setUpdateUserId(entity.getCreateUserId());
+ TokenData tokenData = TokenData.takeFromRequest();
+ if (tokenData != null) {
+ entity.setTenantId(tokenData.getTenantId());
+ entity.setAppCode(tokenData.getAppCode());
+ }
+ // 唯一键 (process_instance_id, task_key) 保证同一节点只有一行,重复写入是覆盖语义;
+ // 用 upsert 而非先查后写,避免并发的任务创建互相覆盖。
+ flowTaskInstanceExtMapper.upsert(entity);
+ }
+
+ @Override
+ public Map getMapByProcessInstanceIds(Collection processInstanceIds) {
+ // 空集合会产生非法的 IN () 语句,这里直接短路返回。
+ if (CollUtil.isEmpty(processInstanceIds)) {
+ return new HashMap<>();
+ }
+ LambdaQueryWrapper qw = new LambdaQueryWrapper<>();
+ qw.in(FlowTaskInstanceExt::getProcessInstanceId, processInstanceIds);
+ return flowTaskInstanceExtMapper.selectList(qw).stream().collect(Collectors.toMap(
+ e -> e.getProcessInstanceId() + ":" + e.getTaskKey(), c -> c, (existOne, newOne) -> existOne));
+ }
+
+ @Transactional(rollbackFor = Exception.class)
+ @Override
+ public void deleteByProcessInstanceId(String processInstanceId) {
+ if (StrUtil.isBlank(processInstanceId)) {
+ return;
+ }
+ flowTaskInstanceExtMapper.delete(new LambdaQueryWrapper()
+ .eq(FlowTaskInstanceExt::getProcessInstanceId, processInstanceId));
+ }
+
+ /**
+ * 把标题截断到列宽以内。
+ * 注意不能用 Hutool 的 StrUtil.maxLength(title, 1024):它是「先截到 1024 再补 ...」,
+ * 返回 1027 个字符,写进 varchar(1024) 会直接被数据库拒绝。
+ *
+ * @param title 解析后的标题。
+ * @return 不超过 TITLE_MAX_LENGTH 的标题。
+ */
+ private String truncateTitle(String title) {
+ if (title.length() <= TITLE_MAX_LENGTH) {
+ return title;
+ }
+ return title.substring(0, TITLE_MAX_LENGTH - TITLE_ELLIPSIS.length()) + TITLE_ELLIPSIS;
+ }
+
+ /**
+ * 取当前操作用户Id。
+ * 本表的写入不总在 HTTP 请求线程里(如超时作业触发自动审批时创建下一个任务),
+ * 此时 TokenData 为空,回落为 0,避免非空约束报错导致标题写入被静默吞掉。
+ *
+ * @return 操作用户Id。
+ */
+ private Long getOperatorId() {
+ TokenData tokenData = TokenData.takeFromRequest();
+ return tokenData == null || tokenData.getUserId() == null ? 0L : tokenData.getUserId();
+ }
+}
diff --git a/common/common-flow/src/main/java/apelet/common/flow/service/impl/FlowTaskTitleBackfillServiceImpl.java b/common/common-flow/src/main/java/apelet/common/flow/service/impl/FlowTaskTitleBackfillServiceImpl.java
new file mode 100644
index 0000000..36b0d3d
--- /dev/null
+++ b/common/common-flow/src/main/java/apelet/common/flow/service/impl/FlowTaskTitleBackfillServiceImpl.java
@@ -0,0 +1,212 @@
+package apelet.common.flow.service.impl;
+
+import apelet.common.core.annotation.MyDataSourceResolver;
+import apelet.common.core.constant.ApplicationConstant;
+import apelet.common.core.object.GlobalThreadLocal;
+import apelet.common.core.object.TokenData;
+import apelet.common.core.util.DefaultDataSourceResolver;
+import apelet.common.flow.model.FlowEntry;
+import apelet.common.flow.model.FlowTaskExt;
+import apelet.common.flow.model.FlowTaskInstanceExt;
+import apelet.common.flow.model.FlowWorkOrder;
+import apelet.common.flow.model.constant.FlowBindFormType;
+import apelet.common.flow.service.FlowApiService;
+import apelet.common.flow.service.FlowEntryService;
+import apelet.common.flow.service.FlowTaskExtService;
+import apelet.common.flow.service.FlowTaskInstanceExtService;
+import apelet.common.flow.service.FlowTaskTitleBackfillService;
+import apelet.common.flow.service.FlowWorkOrderService;
+import apelet.common.flow.vo.FlowTaskTitleBackfillVo;
+import cn.hutool.core.collection.CollUtil;
+import cn.hutool.core.util.BooleanUtil;
+import cn.hutool.core.util.ObjectUtil;
+import cn.hutool.core.util.StrUtil;
+import lombok.extern.slf4j.Slf4j;
+import org.flowable.engine.HistoryService;
+import org.flowable.engine.TaskService;
+import org.flowable.task.api.Task;
+import org.flowable.task.api.history.HistoricTaskInstance;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+/**
+ * 节点标题回填服务实现。
+ *
+ * @author chenchuchuan
+ * @date 2026-10-14
+ */
+@Slf4j
+@MyDataSourceResolver(
+ resolver = DefaultDataSourceResolver.class,
+ intArg = ApplicationConstant.COMMON_FLOW_AND_ONLINE_DATASOURCE_TYPE)
+@Service("flowTaskTitleBackfillService")
+public class FlowTaskTitleBackfillServiceImpl implements FlowTaskTitleBackfillService {
+
+ private static final int DEFAULT_BATCH_SIZE = 200;
+
+ private static final int MAX_BATCH_SIZE = 2000;
+
+ @Autowired
+ private FlowWorkOrderService flowWorkOrderService;
+ @Autowired
+ private FlowEntryService flowEntryService;
+ @Autowired
+ private FlowTaskExtService flowTaskExtService;
+ @Autowired
+ private FlowTaskInstanceExtService flowTaskInstanceExtService;
+ @Autowired
+ private FlowApiService flowApiService;
+ @Autowired
+ private HistoryService historyService;
+ @Autowired
+ private TaskService taskService;
+
+ @Override
+ public FlowTaskTitleBackfillVo backfill(Long cursorWorkOrderId, Integer batchSize, Boolean dryRun) {
+ long startTime = System.currentTimeMillis();
+ int size = batchSize == null || batchSize <= 0 ? DEFAULT_BATCH_SIZE : Math.min(batchSize, MAX_BATCH_SIZE);
+ boolean onlyCount = BooleanUtil.isTrue(dryRun);
+ FlowTaskTitleBackfillVo result = new FlowTaskTitleBackfillVo();
+ result.setInstanceCount(0);
+ result.setNodeCount(0);
+ result.setSavedCount(0);
+ result.setSkippedExistCount(0);
+ result.setSkippedNoTitleCount(0);
+ result.setFailedCount(0);
+ result.setDryRun(onlyCount);
+ // 回填是没有登录态的运维动作,且要跨租户跑;关掉数据权限过滤,
+ // 否则 getMasterData -> buildDataPermFilter -> processDataPerm 会因 TokenData 为空而 NPE。
+ boolean dataFilterEnabled = GlobalThreadLocal.setDataFilter(false);
+ try {
+ List workOrderList = flowWorkOrderService.getListByWorkOrderIdAfter(cursorWorkOrderId, size);
+ for (FlowWorkOrder workOrder : workOrderList) {
+ this.backfillOneInstance(workOrder, onlyCount, result);
+ }
+ result.setInstanceCount(workOrderList.size());
+ result.setFinished(workOrderList.size() < size);
+ result.setNextCursor(workOrderList.isEmpty()
+ ? null : workOrderList.get(workOrderList.size() - 1).getWorkOrderId());
+ } finally {
+ GlobalThreadLocal.setDataFilter(dataFilterEnabled);
+ }
+ result.setElapsedMs(System.currentTimeMillis() - startTime);
+ return result;
+ }
+
+ /**
+ * 回填单个实例下出现过的所有节点。
+ *
+ * @param workOrder 工单对象。
+ * @param dryRun 只统计不写库。
+ * @param result 累计结果。
+ */
+ private void backfillOneInstance(FlowWorkOrder workOrder, boolean dryRun, FlowTaskTitleBackfillVo result) {
+ String processInstanceId = workOrder.getProcessInstanceId();
+ if (StrUtil.isBlank(processInstanceId)) {
+ return;
+ }
+ FlowEntry flowEntry = StrUtil.isBlank(workOrder.getProcessDefinitionKey()) ? null
+ : flowEntryService.getFlowEntryFromCache(workOrder.getProcessDefinitionKey());
+ // 路由表单流程本来就没有标题,跳过
+ if (flowEntry == null || !ObjectUtil.equal(flowEntry.getBindFormType(), FlowBindFormType.ONLINE_FORM)) {
+ return;
+ }
+ // 按实例补一份 TokenData:业务数据查询的租户/应用归属、以及 ${loginName} 之类的占位符解析,
+ // 要尽量贴近当初实时解析时的环境,否则算出来的业务数据可能与线上显示的不一致。
+ this.bindTokenData(workOrder);
+ Set taskKeySet = this.getTaskKeySet(processInstanceId);
+ if (CollUtil.isEmpty(taskKeySet)) {
+ return;
+ }
+ Map existMap = dryRun ? Collections.emptyMap()
+ : flowTaskInstanceExtService.getMapByProcessInstanceIds(Collections.singletonList(processInstanceId));
+ for (String taskKey : taskKeySet) {
+ result.setNodeCount(result.getNodeCount() + 1);
+ if (existMap.containsKey(processInstanceId + ":" + taskKey)) {
+ // 幂等:已有标题行不重算也不覆盖,便于重复执行与中断续跑
+ result.setSkippedExistCount(result.getSkippedExistCount() + 1);
+ continue;
+ }
+ if (dryRun) {
+ continue;
+ }
+ try {
+ String title = this.buildTitle(workOrder, flowEntry, taskKey);
+ if (StrUtil.isBlank(title)) {
+ result.setSkippedNoTitleCount(result.getSkippedNoTitleCount() + 1);
+ continue;
+ }
+ flowTaskInstanceExtService.saveTaskTitle(
+ processInstanceId, workOrder.getProcessDefinitionId(), taskKey, title);
+ result.setSavedCount(result.getSavedCount() + 1);
+ } catch (Exception e) {
+ // 单条失败不影响整批,记数后继续
+ result.setFailedCount(result.getFailedCount() + 1);
+ log.warn("回填节点标题失败。processInstanceId [{}], taskKey [{}]", processInstanceId, taskKey, e);
+ }
+ }
+ }
+
+ /**
+ * 按模板重新解析该节点的标题。
+ * 模板必须用实例自己的 processDefinitionId 取 —— zz_flow_task_ext 的主键是
+ * (process_definition_id, task_id),老实例指向当初发布的那一版定义,用当前主版本会取到改版后的模板。
+ *
+ * @param workOrder 工单对象。
+ * @param flowEntry 流程对象。
+ * @param taskKey 节点Id。
+ * @return 解析后的标题,解析不出返回 null。
+ */
+ private String buildTitle(FlowWorkOrder workOrder, FlowEntry flowEntry, String taskKey) {
+ FlowTaskExt flowTaskExt = flowTaskExtService
+ .getByProcessDefinitionIdAndTaskId(workOrder.getProcessDefinitionId(), taskKey);
+ String titleTemplate = flowTaskExt == null ? null : flowTaskExt.getTitleTemplate();
+ return flowApiService.resolveTitle(titleTemplate, workOrder.getProcessInstanceId(), workOrder, flowEntry);
+ }
+
+ /**
+ * 取该实例出现过的所有节点:已办的历史任务 + 正在待办的任务。
+ * 待办节点也要带上,否则老单据在待办列表里同样没有标题。
+ *
+ * @param processInstanceId 流程实例Id。
+ * @return 节点Id集合。
+ */
+ private Set getTaskKeySet(String processInstanceId) {
+ Set taskKeySet = new HashSet<>();
+ List historicTaskList =
+ historyService.createHistoricTaskInstanceQuery().processInstanceId(processInstanceId).list();
+ for (HistoricTaskInstance task : historicTaskList) {
+ if (StrUtil.isNotBlank(task.getTaskDefinitionKey())) {
+ taskKeySet.add(task.getTaskDefinitionKey());
+ }
+ }
+ List activeTaskList = taskService.createTaskQuery().processInstanceId(processInstanceId).list();
+ for (Task task : activeTaskList) {
+ if (StrUtil.isNotBlank(task.getTaskDefinitionKey())) {
+ taskKeySet.add(task.getTaskDefinitionKey());
+ }
+ }
+ return taskKeySet;
+ }
+
+ /**
+ * 按实例绑定 TokenData,供业务数据查询使用。
+ *
+ * @param workOrder 工单对象。
+ */
+ private void bindTokenData(FlowWorkOrder workOrder) {
+ TokenData tokenData = new TokenData();
+ tokenData.setLoginName(workOrder.getSubmitUsername());
+ tokenData.setTenantId(workOrder.getTenantId());
+ tokenData.setAppCode(workOrder.getAppCode());
+ // 运维动作,跨租户执行,不按某个具体用户的数据权限过滤
+ tokenData.setIsAdmin(Boolean.TRUE);
+ TokenData.addToRequest(tokenData);
+ }
+}
diff --git a/common/common-flow/src/main/java/apelet/common/flow/service/impl/FlowWorkOrderServiceImpl.java b/common/common-flow/src/main/java/apelet/common/flow/service/impl/FlowWorkOrderServiceImpl.java
index a99f702..ac090b3 100644
--- a/common/common-flow/src/main/java/apelet/common/flow/service/impl/FlowWorkOrderServiceImpl.java
+++ b/common/common-flow/src/main/java/apelet/common/flow/service/impl/FlowWorkOrderServiceImpl.java
@@ -17,6 +17,7 @@ import apelet.common.flow.model.FlowWorkOrder;
import apelet.common.flow.model.FlowWorkOrderExt;
import apelet.common.flow.service.FlowApiService;
import apelet.common.flow.service.FlowEntryService;
+import apelet.common.flow.service.FlowTaskInstanceExtService;
import apelet.common.flow.service.FlowWorkOrderService;
import apelet.common.flow.util.BaseFlowIdentityExtHelper;
import apelet.common.flow.util.FlowCustomExtFactory;
@@ -61,6 +62,8 @@ public class FlowWorkOrderServiceImpl extends BaseService i
@Autowired
private FlowEntryService flowEntryService;
@Autowired
+ private FlowTaskInstanceExtService flowTaskInstanceExtService;
+ @Autowired
private CommonRedisUtil commonRedisUtil;
/**
@@ -318,35 +321,15 @@ public class FlowWorkOrderServiceImpl extends BaseService i
new LambdaQueryWrapper().in(FlowWorkOrderExt::getWorkOrderId, workOrderIds));
}
- @Transactional(rollbackFor = Exception.class)
@Override
- public void saveTaskTitle(Long workOrderId, String taskKey, String title) {
- if (workOrderId == null || StrUtil.isBlank(taskKey) || StrUtil.isBlank(title)) {
- return;
- }
- FlowWorkOrderExt existExt = this.getFlowWorkOrderExtByWorkOrderId(workOrderId);
- JSONObject titleJson = existExt == null || StrUtil.isBlank(existExt.getTitleJson())
- ? new JSONObject() : JSON.parseObject(existExt.getTitleJson());
- // 同一实例的每个节点各写各的条目,这里必须是合并写入,整串覆盖会抹掉其他节点已写的标题
- titleJson.put(taskKey, title);
- if (existExt == null) {
- // 工单扩展行并非随工单必建(只有草稿路径会建),首次写入标题时需要补一行
- FlowWorkOrder flowWorkOrder = flowWorkOrderMapper.selectById(workOrderId);
- FlowWorkOrderExt newExt = flowWorkOrder == null
- ? new FlowWorkOrderExt() : BeanUtil.copyProperties(flowWorkOrder, FlowWorkOrderExt.class);
- newExt.setId(idGenerator.nextLongId());
- newExt.setWorkOrderId(workOrderId);
- newExt.setTitleJson(titleJson.toJSONString());
- flowWorkOrderExtMapper.insert(newExt);
- return;
+ public List getListByWorkOrderIdAfter(Long cursorWorkOrderId, int limit) {
+ LambdaQueryWrapper qw = new LambdaQueryWrapper<>();
+ if (cursorWorkOrderId != null) {
+ qw.gt(FlowWorkOrder::getWorkOrderId, cursorWorkOrderId);
}
- // 只更新 title_json,避免把 draft_data / business_data 一并覆盖
- FlowWorkOrderExt updateExt = new FlowWorkOrderExt();
- updateExt.setId(existExt.getId());
- updateExt.setTitleJson(titleJson.toJSONString());
- updateExt.setUpdateTime(new Date());
- updateExt.setUpdateUserId(TokenData.takeFromRequest() == null ? null : TokenData.takeFromRequest().getUserId());
- flowWorkOrderExtMapper.updateById(updateExt);
+ qw.orderByAsc(FlowWorkOrder::getWorkOrderId);
+ qw.last("LIMIT " + Math.max(limit, 1));
+ return flowWorkOrderMapper.selectList(qw);
}
@Transactional(rollbackFor = Exception.class)
@@ -357,6 +340,9 @@ public class FlowWorkOrderServiceImpl extends BaseService i
return r;
}
flowWorkOrderMapper.deleteById(flowWorkOrder.getWorkOrderId());
+ // 工单是逻辑删除,act_hi_taskinst 里的任务行仍在;节点扩展若不跟着删,
+ // 会搜出一张已删除的单据,而列表里那行又没有标题。
+ flowTaskInstanceExtService.deleteByProcessInstanceId(flowWorkOrder.getProcessInstanceId());
return CallResult.ok();
}
diff --git a/common/common-flow/src/main/java/apelet/common/flow/vo/FlowTaskTitleBackfillVo.java b/common/common-flow/src/main/java/apelet/common/flow/vo/FlowTaskTitleBackfillVo.java
new file mode 100644
index 0000000..542b637
--- /dev/null
+++ b/common/common-flow/src/main/java/apelet/common/flow/vo/FlowTaskTitleBackfillVo.java
@@ -0,0 +1,75 @@
+package apelet.common.flow.vo;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+
+/**
+ * 节点标题回填的执行结果,便于通过 curl 直接查看。
+ *
+ * @author chenchuchuan
+ * @date 2026-10-14
+ */
+@Data
+@Schema(description = "节点标题回填结果")
+public class FlowTaskTitleBackfillVo {
+
+ /**
+ * 本批处理的工单(实例)数。
+ */
+ @Schema(description = "本批处理的工单数")
+ private Integer instanceCount;
+
+ /**
+ * 本批遇到的 (实例, 节点) 总数。
+ */
+ @Schema(description = "本批遇到的(实例,节点)总数")
+ private Integer nodeCount;
+
+ /**
+ * 实际写入的标题条数。
+ */
+ @Schema(description = "写入条数")
+ private Integer savedCount;
+
+ /**
+ * 已有标题行、直接跳过的条数。
+ */
+ @Schema(description = "已有标题行跳过的条数")
+ private Integer skippedExistCount;
+
+ /**
+ * 算不出标题(非在线表单 / 模板为空 / 业务数据取不到)而跳过的条数。
+ */
+ @Schema(description = "算不出标题跳过的条数")
+ private Integer skippedNoTitleCount;
+
+ /**
+ * 单条处理异常的次数。异常不会中断整体回填。
+ */
+ @Schema(description = "异常次数")
+ private Integer failedCount;
+
+ /**
+ * 下一批的游标。为 null 表示已处理完。
+ */
+ @Schema(description = "下一批游标,null 表示已跑完")
+ private Long nextCursor;
+
+ /**
+ * 是否已经处理完所有工单。
+ */
+ @Schema(description = "是否已跑完")
+ private Boolean finished;
+
+ /**
+ * 是否为试运行(只统计不写库)。
+ */
+ @Schema(description = "是否试运行")
+ private Boolean dryRun;
+
+ /**
+ * 本次请求耗时(毫秒)。
+ */
+ @Schema(description = "耗时毫秒")
+ private Long elapsedMs;
+}