Browse Source

feat(flow): 新增任务标题关键字过滤并重构标题存储至节点扩展表

- 将标题存储从 zz_flow_work_order_ext.title_json 迁移至独立表 zz_flow_task_instance_ext
- getTaskListByUserName 新增 title 参数,待办列表支持按标题模糊过滤(Java 侧过滤)
- getHistoricProcessInstanceList 新增 title 参数,走 Flowable 原生 join 查询实现首节点标题过滤
- getHistoricTaskInstanceFinishedList 新增 title 参数,走原生 join 查询实现已办任务标题过滤
- resolveTitleCached 简化签名,仅读取落库值,移除按模板实时解析的兜底逻辑
- buildAndSaveTaskTitle 首节点补写改为取定义的起始节点而非当前活动任务,修复 autoskip 场景
- 删除流程实例时级联清理 zz_flow_task_instance_ext 中的标题行
- 新增 escapeLikeKeyword 转义 LIKE 通配符,防止用户输入注入
- 新增 TitleFilterContext 内部类封装标题过滤的懒加载与缓存逻辑
pull/2/head
chenchuchuan 2 weeks ago
parent
commit
9d968d549f
  1. 94
      common/common-flow/src/main/java/apelet/common/flow/controller/FlowOperationController.java
  2. 22
      common/common-flow/src/main/java/apelet/common/flow/dao/FlowTaskInstanceExtMapper.java
  3. 31
      common/common-flow/src/main/java/apelet/common/flow/dao/mapper/FlowTaskInstanceExtMapper.xml
  4. 8
      common/common-flow/src/main/java/apelet/common/flow/listener/FlowUserTaskListener.java
  5. 86
      common/common-flow/src/main/java/apelet/common/flow/model/FlowTaskInstanceExt.java
  6. 8
      common/common-flow/src/main/java/apelet/common/flow/model/FlowWorkOrderExt.java
  7. 43
      common/common-flow/src/main/java/apelet/common/flow/service/FlowApiService.java
  8. 43
      common/common-flow/src/main/java/apelet/common/flow/service/FlowTaskInstanceExtService.java
  9. 25
      common/common-flow/src/main/java/apelet/common/flow/service/FlowTaskTitleBackfillService.java
  10. 14
      common/common-flow/src/main/java/apelet/common/flow/service/FlowWorkOrderService.java
  11. 371
      common/common-flow/src/main/java/apelet/common/flow/service/impl/FlowApiServiceImpl.java
  12. 139
      common/common-flow/src/main/java/apelet/common/flow/service/impl/FlowTaskInstanceExtServiceImpl.java
  13. 212
      common/common-flow/src/main/java/apelet/common/flow/service/impl/FlowTaskTitleBackfillServiceImpl.java
  14. 40
      common/common-flow/src/main/java/apelet/common/flow/service/impl/FlowWorkOrderServiceImpl.java
  15. 75
      common/common-flow/src/main/java/apelet/common/flow/vo/FlowTaskTitleBackfillVo.java

94
common/common-flow/src/main/java/apelet/common/flow/controller/FlowOperationController.java

@ -2,6 +2,7 @@ package apelet.common.flow.controller; @@ -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; @@ -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 { @@ -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 { @@ -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 { @@ -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 { @@ -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<Task> pageData = flowApiService.getTaskListByUserName(
username, processDefinitionKey, processDefinitionName, taskName, pageParam);
username, processDefinitionKey, processDefinitionName, taskName, title, pageParam);
List<FlowTaskVo> flowTaskVoList = flowApiService.convertToFlowTaskList(pageData.getDataList());
return ResponseResult.success(MyPageUtil.makeResponseData(flowTaskVoList, pageData.getTotalCount()));
}
@ -811,6 +817,7 @@ public class FlowOperationController { @@ -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 { @@ -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<HistoricTaskInstance> pageData =
flowApiService.getHistoricTaskInstanceFinishedList(processDefinitionName, beginDate, endDate, pageParam);
flowApiService.getHistoricTaskInstanceFinishedList(processDefinitionName, beginDate, endDate, title, pageParam);
List<Map<String, Object>> resultList = new LinkedList<>();
pageData.getDataList().forEach(instance -> resultList.add(BeanUtil.beanToMap(instance)));
List<HistoricTaskInstance> taskInstanceList = pageData.getDataList();
@ -842,11 +850,9 @@ public class FlowOperationController { @@ -842,11 +850,9 @@ public class FlowOperationController {
flowWorkOrderService.getInList(INSTANCE_ID, instanceIdSet);
Map<String, FlowWorkOrder> workOrderMap =
workOrderList.stream().collect(Collectors.toMap(FlowWorkOrder::getProcessInstanceId, c -> c));
// 标题快照按工单批量预载(一次 IN 查询覆盖整页),避免逐行查扩展对象造成 N+1。
Map<Long, FlowWorkOrderExt> 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<String, FlowTaskInstanceExt> taskInstanceExtMap =
flowTaskInstanceExtService.getMapByProcessInstanceIds(instanceIdSet);
Set<String> processDefinitionIdSet = taskInstanceList.stream()
.map(HistoricTaskInstance::getProcessDefinitionId).collect(Collectors.toSet());
List<FlowEntryPublish> flowEntryPublishList =
@ -880,10 +886,9 @@ public class FlowOperationController { @@ -880,10 +886,9 @@ public class FlowOperationController {
FlowEntry flowEntry = flowEntryMap.get(flowEntryPublish.getEntryId());
if (flowEntry != null) {
Object taskDefinitionKey = result.get("taskDefinitionKey");
result.put("title", flowApiService.resolveTitleCached(
flowTaskExt.getTitleTemplate(), instanceId, flowWorkOrder, flowEntry,
taskDefinitionKey == null ? null : taskDefinitionKey.toString(),
workOrderExtMap.get(flowWorkOrder.getWorkOrderId())));
String taskKey = taskDefinitionKey == null ? null : taskDefinitionKey.toString();
result.put("title", flowApiService.resolveTitleCached(flowWorkOrder, taskKey,
taskKey == null ? null : taskInstanceExtMap.get(instanceId + ":" + taskKey)));
}
}
}
@ -910,6 +915,7 @@ public class FlowOperationController { @@ -910,6 +915,7 @@ public class FlowOperationController {
* @param processDefinitionName 流程名。
* @param beginDate 流程发起开始时间。
* @param endDate 流程发起结束时间。
* @param title 首节点标题关键字,模糊匹配。
* @param pageParam 分页对象。
* @return 查询结果应答。
*/
@ -919,10 +925,11 @@ public class FlowOperationController { @@ -919,10 +925,11 @@ public class FlowOperationController {
@MyRequestBody String processDefinitionName,
@MyRequestBody String beginDate,
@MyRequestBody String endDate,
@MyRequestBody String title,
@MyRequestBody(required = true) MyPageParam pageParam) throws ParseException {
String loginName = TokenData.takeFromRequest().getLoginName();
MyPageData<HistoricProcessInstance> pageData = flowApiService.getHistoricProcessInstanceList(
null, processDefinitionName, loginName, beginDate, endDate, pageParam, true);
null, processDefinitionName, loginName, beginDate, endDate, title, pageParam, true);
Set<String> loginNameSet = pageData.getDataList().stream()
.map(HistoricProcessInstance::getStartUserId).collect(Collectors.toSet());
List<FlowUserInfoVo> userInfoList = flowCustomExtFactory
@ -938,11 +945,8 @@ public class FlowOperationController { @@ -938,11 +945,8 @@ public class FlowOperationController {
flowWorkOrderService.getInList(INSTANCE_ID, instanceIdSet);
Map<String, FlowWorkOrder> workOrderMap =
workOrderList.stream().collect(Collectors.toMap(FlowWorkOrder::getProcessInstanceId, c -> c));
// 标题快照按工单批量预载(一次 IN 查询覆盖整页),避免逐行查扩展对象造成 N+1。
Map<Long, FlowWorkOrderExt> 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<String, FlowTaskInstanceExt> taskInstanceExtMap = flowTaskInstanceExtService.getMapByProcessInstanceIds(instanceIdSet);
Set<String> processDefinitionIdSet = pageData.getDataList().stream()
.map(HistoricProcessInstance::getProcessDefinitionId).collect(Collectors.toSet());
Map<String, FlowEntryPublish> flowEntryPublishMap = new HashMap<>();
@ -989,10 +993,10 @@ public class FlowOperationController { @@ -989,10 +993,10 @@ public class FlowOperationController {
if (flowEntryPublish != null && flowTaskExt != null) {
FlowEntry flowEntry = flowEntryMap.get(flowEntryPublish.getEntryId());
if (flowEntry != null) {
data.put("title", flowApiService.resolveTitleCached(
flowTaskExt.getTitleTemplate(), instance.getId(), workOrder, flowEntry,
initTaskKeyMap.get(instance.getProcessDefinitionId()),
workOrderExtMap.get(workOrder.getWorkOrderId())));
// 流程实例列表使用起始节点的标题。
String initTaskKey = initTaskKeyMap.get(instance.getProcessDefinitionId());
data.put("title", flowApiService.resolveTitleCached(workOrder, initTaskKey,
initTaskKey == null ? null : taskInstanceExtMap.get(instance.getId() + ":" + initTaskKey)));
}
}
}
@ -1019,7 +1023,8 @@ public class FlowOperationController { @@ -1019,7 +1023,8 @@ public class FlowOperationController {
@MyRequestBody String endDate,
@MyRequestBody(required = true) MyPageParam pageParam) throws ParseException {
MyPageData<HistoricProcessInstance> pageData = flowApiService.getHistoricProcessInstanceList(
null, processDefinitionName, startUser, beginDate, endDate, pageParam, false);
// listAllHistoricProcessInstance 是管理端「所有历史流程」,本次不开放标题搜索,沿用原行为
null, processDefinitionName, startUser, beginDate, endDate, null, pageParam, false);
List<Map<String, Object>> resultList = new LinkedList<>();
pageData.getDataList().forEach(instance -> resultList.add(BeanUtil.beanToMap(instance)));
List<String> unfinishedProcessInstanceIds = pageData.getDataList().stream()
@ -1309,4 +1314,33 @@ public class FlowOperationController { @@ -1309,4 +1314,33 @@ public class FlowOperationController {
}
return null;
}
/**
* 回填节点标题(运维接口)。
* <p>改造前节点标题是查询时实时解析的、从不落库,所以存量流程实例没有标题数据。本接口按
* 「流程实例 × 节点」重新解析一遍并落到 zz_flow_task_instance_ext。</p>
* <p><b>免鉴权</b>({@link NoAuthInterface}),方便直接在服务器上 curl。因此做了这些防护:</p>
* <ul>
* <li>幂等 —— 已有标题行的 (实例, 节点) 直接跳过,重复调用不会产生重复数据,也不会覆盖已生成的值;</li>
* <li>只处理在线表单流程(路由表单本来没有标题),且只处理工单仍存在的实例;</li>
* <li>单条失败只记数不中断,可反复重跑补齐;</li>
* <li>按游标分批,避免一次请求跑太久。</li>
* </ul>
* <p>用法:先 dryRun=true 看体量,再去掉 dryRun 正式跑;返回的 finished 为 true 表示已跑完,
* 否则把 nextCursor 带上再调一次。</p>
*
* @param cursorWorkOrderId 游标,取工单Id大于该值的记录;不传表示从头开始。
* @param batchSize 单批工单数,默认 200,上限 2000。
* @param dryRun 为 true 时只统计不写库。
* @return 本批执行结果。
*/
@NoAuthInterface
@PostMapping("/backfillTaskTitle")
public ResponseResult<FlowTaskTitleBackfillVo> backfillTaskTitle(
@RequestParam(required = false) Long cursorWorkOrderId,
@RequestParam(required = false) Integer batchSize,
@RequestParam(required = false) Boolean dryRun) {
return ResponseResult.success(
flowTaskTitleBackfillService.backfill(cursorWorkOrderId, batchSize, dryRun));
}
}

22
common/common-flow/src/main/java/apelet/common/flow/dao/FlowTaskInstanceExtMapper.java

@ -0,0 +1,22 @@ @@ -0,0 +1,22 @@
package apelet.common.flow.dao;
import apelet.common.core.base.dao.BaseDaoMapper;
import apelet.common.flow.model.FlowTaskInstanceExt;
/**
* 流程节点扩展(实例级)数据操作访问接口。
*
* @author chenchuchuan
* @date 2026-10-13
*/
public interface FlowTaskInstanceExtMapper extends BaseDaoMapper<FlowTaskInstanceExt> {
/**
* 按 (process_instance_id, task_key) 唯一键写入节点扩展,已存在则更新。
* <p>用 ON DUPLICATE KEY UPDATE 而非「先查后写」,是为了让并发的多个任务创建落到同一条 SQL 上,
* 避免读-改-写之间互相覆盖,也天然保证同一节点只有一行。</p>
*
* @param flowTaskInstanceExt 节点扩展对象。
*/
void upsert(FlowTaskInstanceExt flowTaskInstanceExt);
}

31
common/common-flow/src/main/java/apelet/common/flow/dao/mapper/FlowTaskInstanceExtMapper.xml

@ -0,0 +1,31 @@ @@ -0,0 +1,31 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="apelet.common.flow.dao.FlowTaskInstanceExtMapper">
<resultMap id="BaseResultMap" type="apelet.common.flow.model.FlowTaskInstanceExt">
<id column="id" jdbcType="BIGINT" property="id"/>
<result column="tenant_id" jdbcType="BIGINT" property="tenantId"/>
<result column="app_code" jdbcType="VARCHAR" property="appCode"/>
<result column="process_instance_id" jdbcType="VARCHAR" property="processInstanceId"/>
<result column="process_definition_id" jdbcType="VARCHAR" property="processDefinitionId"/>
<result column="task_key" jdbcType="VARCHAR" property="taskKey"/>
<result column="title" jdbcType="VARCHAR" property="title"/>
<result column="create_time" jdbcType="TIMESTAMP" property="createTime"/>
<result column="create_user_id" jdbcType="BIGINT" property="createUserId"/>
<result column="update_time" jdbcType="TIMESTAMP" property="updateTime"/>
<result column="update_user_id" jdbcType="BIGINT" property="updateUserId"/>
</resultMap>
<insert id="upsert" parameterType="apelet.common.flow.model.FlowTaskInstanceExt">
INSERT INTO zz_flow_task_instance_ext
(id, tenant_id, app_code, process_instance_id, process_definition_id, task_key, title,
create_time, create_user_id, update_time, update_user_id)
VALUES
(#{id}, #{tenantId}, #{appCode}, #{processInstanceId}, #{processDefinitionId}, #{taskKey}, #{title},
#{createTime}, #{createUserId}, #{updateTime}, #{updateUserId})
ON DUPLICATE KEY UPDATE
title = VALUES(title),
process_definition_id = VALUES(process_definition_id),
update_time = VALUES(update_time),
update_user_id = VALUES(update_user_id)
</insert>
</mapper>

8
common/common-flow/src/main/java/apelet/common/flow/listener/FlowUserTaskListener.java

@ -41,9 +41,11 @@ public class FlowUserTaskListener implements TaskListener { @@ -41,9 +41,11 @@ public class FlowUserTaskListener implements TaskListener {
}
/**
* 任务创建时按该节点配置的标题模板生成标题,写入工单扩展的 title_json,查询时直接读取。
* 任务创建时按该节点配置的标题模板生成标题,写入 zz_flow_task_instance_ext,查询时直接读取。
* <p>首节点的 create 事件发生在工单创建之前(工单在 flowApiService.start 返回之后才建),此处取不到工单,
* 交由 FlowWorkOrderService#saveNew 在建单后补写,所以这里直接返回。</p>
* 交由 FlowWorkOrderService#saveNew 在建单后按起始节点补写,所以这里直接返回。</p>
*
* @param delegateTask 当前任务对象。
*/
private void buildTaskTitle(DelegateTask delegateTask) {
try {
@ -57,7 +59,7 @@ public class FlowUserTaskListener implements TaskListener { @@ -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);
}
}

86
common/common-flow/src/main/java/apelet/common/flow/model/FlowTaskInstanceExt.java

@ -0,0 +1,86 @@ @@ -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;
/**
* 流程节点扩展实体对象(实例级)。
* <p>业务键是 (processInstanceId, taskKey),即「流程实例 × 节点」,一行一个节点。</p>
*
* @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;
}

8
common/common-flow/src/main/java/apelet/common/flow/model/FlowWorkOrderExt.java

@ -40,14 +40,6 @@ public class FlowWorkOrderExt { @@ -40,14 +40,6 @@ public class FlowWorkOrderExt {
private String businessData;
/**
* 节点标题快照,JSON:{"taskKey": "解析后的标题"}。
* <p>工单是「一个流程实例一行」,标题则是「实例 × 节点」级,故按 BPMN 节点Id(taskKey) 存成 JSON 承载多节点。
* 由 FlowUserTaskListener 在任务创建时写入,查询时优先读取,取不到再回退实时解析。</p>
*/
@TableField(value = "title_json")
private String titleJson;
/**
* 更新时间。
*/
@TableField(value = "update_time")

43
common/common-flow/src/main/java/apelet/common/flow/service/FlowApiService.java

@ -4,8 +4,8 @@ import apelet.common.core.object.*; @@ -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 { @@ -281,11 +281,12 @@ public interface FlowApiService {
* @param definitionKey 流程定义的标识。
* @param definitionName 流程定义名。
* @param taskName 任务名称。
* @param title 节点标题关键字,模糊匹配;为空时不按标题过滤。
* @param pageParam 分页对象。
* @return 用户的任务列表。
*/
MyPageData<Task> getTaskListByUserName(
String username, String definitionKey, String definitionName, String taskName, MyPageParam pageParam);
MyPageData<Task> getTaskListByUserName(String username, String definitionKey, String definitionName,
String taskName, String title, MyPageParam pageParam);
/**
* 获取用户的任务数量。这其中包括当前用户作为指派人和候选人。
@ -375,6 +376,7 @@ public interface FlowApiService { @@ -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 { @@ -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 { @@ -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 { @@ -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 { @@ -661,31 +666,29 @@ public interface FlowApiService {
String resolveTitle(String titleTemplate, String processInstanceId, FlowWorkOrder workOrder, FlowEntry flowEntry);
/**
* 优先读取已生成的标题快照,取不到再回退 {@link #resolveTitle} 实时解析。
* <p>快照在任务创建时由 {@link #buildAndSaveTaskTitle} 写入工单扩展的 titleJson,按 taskKey 定位。
* 存量数据(改造前已跑的实例)没有快照,会自动走回退分支,因此无需刷数据。</p>
* 读取已生成的节点标题。
* <p>标题在任务创建时由 {@link #buildAndSaveTaskTitle} 写入 zz_flow_task_instance_ext,按 (实例, 节点) 定位。
* 本方法只读落库值,<b>不做实时解析兜底</b>:没有标题行就返回 null,列表上该行标题为空。
* 因此存量数据(改造前已跑的实例)标题会为空,需要回填后才有。</p>
*
* @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);
/**
* 生成指定节点的标题快照并落库。
* <p>取自该节点配置的 title_template,用当时的流程变量与在线表单业务数据解析后写入
* {@code zz_flow_work_order_ext.title_json}。非在线表单流程、工单为空、标题为空时不写入。</p>
* 生成指定节点的标题并落库到 zz_flow_task_instance_ext。
* <p>取自该节点配置的 title_template,用当时的流程变量与在线表单业务数据解析后写入。
* 非在线表单流程、工单为空、标题为空时不写入。</p>
* <p>注意:首节点的 create 事件触发时工单尚未创建(工单在 flowApiService.start 返回后才建),
* 该场景下 taskKey 传空由本方法自行取当前活动任务节点,供工单创建后补写首节点标题使用。</p>
* 该场景下 taskKey 传空,由本方法取该定义的起始节点 taskKey 补写。不能用「当前活动任务」兜底 ——
* autoskip 节点在 start() 内部就被 complete 了,那时取到的已经不是首节点。</p>
*
* @param workOrder 流程工单对象。
* @param processDefinitionId 流程定义Id,用于定位节点模板。
* @param taskKey BPMN 节点Id,为空时取该实例当前的活动任务节点。
* @param taskKey BPMN 节点Id,为空时取该定义的起始节点。
* @return 写入的标题;未写入返回null。
*/
String buildAndSaveTaskTitle(FlowWorkOrder workOrder, String processDefinitionId, String taskKey);

43
common/common-flow/src/main/java/apelet/common/flow/service/FlowTaskInstanceExtService.java

@ -0,0 +1,43 @@ @@ -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<FlowTaskInstanceExt, Long> {
/**
* 写入指定实例下某节点的标题。
* <p>业务键是 (processInstanceId, taskKey),同一节点重复写入是覆盖语义,不会产生重复行。</p>
*
* @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<String, FlowTaskInstanceExt> getMapByProcessInstanceIds(Collection<String> processInstanceIds);
/**
* 按实例删除全部节点扩展。
* <p>流程实例被物理删除、或草稿工单被删除时调用,避免留下孤儿行、或搜出已删除的单据。</p>
*
* @param processInstanceId 流程实例Id。
*/
void deleteByProcessInstanceId(String processInstanceId);
}

25
common/common-flow/src/main/java/apelet/common/flow/service/FlowTaskTitleBackfillService.java

@ -0,0 +1,25 @@ @@ -0,0 +1,25 @@
package apelet.common.flow.service;
import apelet.common.flow.vo.FlowTaskTitleBackfillVo;
/**
* 节点标题回填服务。
* <p>用于把改造前就已存在的流程实例的节点标题补齐到 zz_flow_task_instance_ext。
* 改造前标题是查询时实时算的、从不落库,所以存量实例没有标题数据。</p>
*
* @author chenchuchuan
* @date 2026-10-14
*/
public interface FlowTaskTitleBackfillService {
/**
* 回填一批工单的节点标题。
* <p>按工单Id游标分批,幂等(已有标题行的 (实例, 节点) 直接跳过),可重复执行、可中断续跑。</p>
*
* @param cursorWorkOrderId 游标,取工单Id大于该值的记录;传 null 表示从头开始。
* @param batchSize 单批工单数,为空或非法时取默认值。
* @param dryRun 为 true 时只统计不写库。
* @return 本批执行结果,其中 nextCursor 为下一批游标,finished 表示是否已跑完。
*/
FlowTaskTitleBackfillVo backfill(Long cursorWorkOrderId, Integer batchSize, Boolean dryRun);
}

14
common/common-flow/src/main/java/apelet/common/flow/service/FlowWorkOrderService.java

@ -160,15 +160,15 @@ public interface FlowWorkOrderService extends IBaseService<FlowWorkOrder, Long> @@ -160,15 +160,15 @@ public interface FlowWorkOrderService extends IBaseService<FlowWorkOrder, Long>
List<FlowWorkOrderExt> getFlowWorkOrderExtByWorkOrderIds(Set<Long> workOrderIds);
/**
* 写入指定工单下某节点的标题快照。
* <p>titleJson 是「实例 × 节点」的映射,写入时按 taskKey 合并(读-改-写),
* 保留其他节点已写入的条目,不能整串覆盖。扩展行不存在时自动创建。</p>
* 按工单Id游标分批取工单,供离线/批量任务遍历使用。
* <p>与 {@link #getFlowWorkOrderList} 不同,本方法不做租户过滤、不依赖请求上下文,
* 否则在无登录态的维护接口里会因 TokenData 为空而 NPE。</p>
*
* @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<FlowWorkOrder> getListByWorkOrderIdAfter(Long cursorWorkOrderId, int limit);
/**
* 移除草稿工单,同时停止已经启动的流程实例。

371
common/common-flow/src/main/java/apelet/common/flow/service/impl/FlowApiServiceImpl.java

@ -23,6 +23,7 @@ import apelet.common.flow.util.BaseFlowIdentityExtHelper; @@ -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; @@ -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 { @@ -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 { @@ -690,8 +694,8 @@ public class FlowApiServiceImpl implements FlowApiService {
}
@Override
public MyPageData<Task> getTaskListByUserName(
String username, String definitionKey, String definitionName, String taskName, MyPageParam pageParam) {
public MyPageData<Task> 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 { @@ -705,10 +709,13 @@ public class FlowApiServiceImpl implements FlowApiService {
this.buildCandidateCondition(query, username);
query.orderByTaskCreateTime().desc();
Map<String, Boolean> 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<Task> 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 { @@ -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 { @@ -724,7 +732,8 @@ public class FlowApiServiceImpl implements FlowApiService {
* 多实例会签节点在流程图中将全部会签人同时配置为候选者,导致每个实例对所有人可见。
* 这里只保留会签实例归属人自己的那一条,其他会签人的实例不参与统计。
*/
private long countOwnedTaskList(TaskQuery query, String username, Map<String, Boolean> multiInstanceTaskMap) {
private long countOwnedTaskList(TaskQuery query, String username, Map<String, Boolean> multiInstanceTaskMap,
TitleFilterContext titleFilter) {
long totalCount = 0;
int cursor = 0;
while (true) {
@ -733,8 +742,10 @@ public class FlowApiServiceImpl implements FlowApiService { @@ -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 { @@ -747,7 +758,8 @@ public class FlowApiServiceImpl implements FlowApiService {
* 多实例会签节点的实例对候选者全体可见,分页时跳过不属于当前用户的其他会签实例。
*/
private List<Task> listOwnedTaskPage(
TaskQuery query, String username, int firstResult, int pageSize, Map<String, Boolean> multiInstanceTaskMap) {
TaskQuery query, String username, int firstResult, int pageSize, Map<String, Boolean> multiInstanceTaskMap,
TitleFilterContext titleFilter) {
List<Task> resultList = new LinkedList<>();
int skipCount = firstResult;
int cursor = 0;
@ -757,8 +769,10 @@ public class FlowApiServiceImpl implements FlowApiService { @@ -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 { @@ -775,6 +789,83 @@ public class FlowApiServiceImpl implements FlowApiService {
}
/**
* 按批懒加载标题:只查本批里还没加载过的实例,避免逐条查库。
*
* @param batch 本批任务。
* @param titleFilter 标题过滤上下文,关键字为空时直接跳过、不做任何查询。
*/
private void loadTitles(Collection<Task> batch, TitleFilterContext titleFilter) {
if (!titleFilter.enabled()) {
return;
}
Set<String> 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));
}
}
/**
* 判断任务标题是否命中关键字。
* <p>标题只认 zz_flow_task_instance_ext 的落库值:没有标题行的一律不匹配,与列表展示口径一致。</p>
*
* @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);
}
/**
* 待办列表标题过滤的上下文。
* <p>标题存在 zz_flow_task_instance_ext 上,Flowable 的查询构造器表达不了这个条件,只能在 Java 侧过滤;
* 而过滤时又要按实例懒加载标题、避免逐条查库。关键字、已加载实例集合、标题缓存这三样永远成组出现,
* 抽成一个对象,免得在几个方法之间来回传六个参数。</p>
*/
private static class TitleFilterContext {
/**
* 标题关键字;为空表示不按标题过滤。
*/
private final String keyword;
/**
* 已经加载过标题的实例Id,避免同一实例重复查库。
*/
private final Set<String> loadedInstanceIdSet = new HashSet<>();
/**
* 标题缓存,key 为 processInstanceId + ":" + taskKey。
*/
private final Map<String, FlowTaskInstanceExt> 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 { @@ -906,11 +997,9 @@ public class FlowApiServiceImpl implements FlowApiService {
flowWorkOrderService.getInList("processInstanceId", procInstanceIdSet);
Map<String, FlowWorkOrder> workOrderMap =
workOrderList.stream().collect(Collectors.toMap(FlowWorkOrder::getProcessInstanceId, c -> c));
// 标题快照按工单批量预载(一次 IN 查询覆盖整页),避免逐行查扩展对象造成 N+1。
Map<Long, FlowWorkOrderExt> 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<String, FlowTaskInstanceExt> taskInstanceExtMap =
flowTaskInstanceExtService.getMapByProcessInstanceIds(procInstanceIdSet);
List<FlowTaskExt> allFlowTaskExtList = flowTaskExtService.getByProcessDefinitionId(processDefinitionIdSet);
Map<String, FlowTaskExt> flowTaskExtMap = allFlowTaskExtList.stream().collect(Collectors.toMap(t -> t.getProcessDefinitionId() + ":" + t.getTaskId(), t -> t, (k1, k2) -> k1));
Map<String, Boolean> multiInstanceTaskMap = new HashMap<>();
@ -943,9 +1032,8 @@ public class FlowApiServiceImpl implements FlowApiService { @@ -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 { @@ -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 { @@ -1062,13 +1158,87 @@ public class FlowApiServiceImpl implements FlowApiService {
return new MyPageData<>(instanceList, totalCount);
}
/**
* 按首节点标题关键字查询历史流程实例,走 Flowable 的原生 join 查询。
* <p>过滤条件逐个复刻自 {@code HistoricProcessInstanceQuery}(源码 HistoricProcessInstance.xml),
* 漏一个就是 bug,且只在带关键字时复现:
* processDefinitionKey→DEF.KEY_;processDefinitionName→DEF.NAME_;startedBy→RES.START_USER_ID_;
* startedAfter/startedBefore→RES.START_TIME_ &gt;= / &lt;= (注意是闭区间);
* finished→RES.END_TIME_ is not null;tenantId/withoutTenantId→RES.TENANT_ID_ 等值、或空串/NULL;
* orderByProcessInstanceStartTime().desc()→order by RES.START_TIME_ desc。</p>
* <p>标题口径与列表展示保持一致 —— 只取<b>首节点</b>的标题。首节点 taskKey 存在发布版本的
* {@code zz_flow_entry_publish.init_task_info} 里,随流程定义而变,所以要在 SQL 里用 JSON 函数解析。
* init_task_info 是 TEXT,可能不是合法 JSON,先用 CASE + JSON_VALID 挡一道,避免脏数据让整个查询报错。</p>
* <p>表名 ACT_HI_PROCINST / ACT_RE_PROCDEF 必须写死 —— Flowable 原生查询不支持 ${prefix} 占位符。
* 本工程未配置 flowable 的 database-table-prefix,将来若配置,此处表名需同步修改;
* Flowable 升级时也需复查列名与语义。</p>
*/
private MyPageData<HistoricProcessInstance> getHistoricProcessInstanceListByTitle(
String processDefinitionKey, String processDefinitionName, String startUser,
String beginDate, String endDate, String title, MyPageParam pageParam,
boolean finishedOnly) throws ParseException {
Map<String, Object> 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<HistoricTaskInstance> 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 { @@ -1100,6 +1270,109 @@ public class FlowApiServiceImpl implements FlowApiService {
return new MyPageData<>(instanceList, totalCount);
}
/**
* 按节点标题关键字查询当前用户已办理的任务,走 Flowable 的原生 join 查询。
* <p>过滤条件逐个复刻自 {@code HistoricTaskInstanceQuery}(源码 HistoricTaskInstance.xml),漏一个就是 bug,
* 且只在带关键字时复现:
* taskAssignee→ASSIGNEE_;finished→END_TIME_ is not null;taskCompletedAfter/Before→END_TIME_ &gt; / &lt;;
* processDefinitionName→ACT_RE_PROCDEF 的 exists 子查询;taskTenantId/taskWithoutTenantId→TENANT_ID_ 等值、
* 或空串/NULL;orderByHistoricTaskInstanceEndTime().desc()→order by END_TIME_ desc。</p>
* <p>表名 ACT_HI_TASKINST 必须写死 —— Flowable 的原生查询不支持 ${prefix} 占位符(实测会被渲染成字面量)。
* 本工程未配置 flowable 的 database-table-prefix,将来若配置,此处表名需同步修改;
* Flowable 升级时也需复查上述列名与语义。</p>
* <p>另: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。</p>
*/
private MyPageData<HistoricTaskInstance> getHistoricTaskInstanceFinishedListByTitle(
String processDefinitionName, String beginDate, String endDate, String title,
String loginName, MyPageParam pageParam) throws ParseException {
Map<String, Object> 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 的通配符,避免用户输入里的 % 和 _ 被当成通配符。
* <p>反斜杠是 MySQL 的默认转义符,自身要先转义。</p>
*
* @param keyword 用户输入的关键字。
* @return 转义后的关键字。
*/
private String escapeLikeKeyword(String keyword) {
return keyword.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_");
}
/**
* 执行「list + count 两条同 FROM/WHERE 的原生查询」,两处原生标题查询共用。
* <p>count 与 list 必须共用同一套 FROM/WHERE、且 count 不能带 ORDER BY,否则 totalCount 与页内容会对不上。</p>
*
* @param listQuery 已设置好 sql 的 list 查询。
* @param countQuery 已设置好 sql 的 count 查询。
* @param params 绑参,会同时应用到两条查询。
* @param pageParam 分页对象。
* @param <R> 查询返回的行类型。
* @return 分页结果。
*/
private <R> MyPageData<R> executeNativeTitleQuery(
NativeQuery<?, R> listQuery, NativeQuery<?, R> countQuery,
Map<String, Object> params, MyPageParam pageParam) {
for (Map.Entry<String, Object> 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);
}
/**
* 追加租户过滤条件,两处原生标题查询共用。
* <p>语义与 Flowable 的 taskTenantId / taskWithoutTenantId(历史流程侧是 processInstanceTenantId /
* processInstanceWithoutTenantId)保持一致:有租户Id走等值;既没有租户Id也没有 appCode 时按"无租户"处理
* (空串或 NULL);否则把 appCode 当租户。</p>
*
* @param where 正在拼装的 WHERE 片段。
* @param params 绑参容器,tenantId 参数由本方法写入。
*/
private void appendTenantCondition(StringBuilder where, Map<String, Object> 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<HistoricActivityInstance> getHistoricActivityInstanceList(String processInstanceId) {
return historyService.createHistoricActivityInstanceQuery().processInstanceId(processInstanceId).list();
@ -1183,6 +1456,9 @@ public class FlowApiServiceImpl implements FlowApiService { @@ -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 { @@ -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 { @@ -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 { @@ -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。
* <p>来源是该发布版本的 initTaskInfo,与历史流程列表(FlowOperationController#listHistoricProcessInstance)
* 取首节点标题模板的口径保持一致。</p>
*
* @param processDefinitionId 流程定义Id。
* @return 起始节点 taskKey,取不到返回null。
*/
private String getInitTaskKey(String processDefinitionId) {
if (StrUtil.isBlank(processDefinitionId)) {
return null;
}
List<FlowEntryPublish> 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,原样返回。

139
common/common-flow/src/main/java/apelet/common/flow/service/impl/FlowTaskInstanceExtServiceImpl.java

@ -0,0 +1,139 @@ @@ -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<FlowTaskInstanceExt, Long>
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<FlowTaskInstanceExt> 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<String, FlowTaskInstanceExt> getMapByProcessInstanceIds(Collection<String> processInstanceIds) {
// 空集合会产生非法的 IN () 语句,这里直接短路返回。
if (CollUtil.isEmpty(processInstanceIds)) {
return new HashMap<>();
}
LambdaQueryWrapper<FlowTaskInstanceExt> 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<FlowTaskInstanceExt>()
.eq(FlowTaskInstanceExt::getProcessInstanceId, processInstanceId));
}
/**
* 把标题截断到列宽以内。
* <p>注意不能用 Hutool 的 StrUtil.maxLength(title, 1024):它是「先截到 1024 再补 ...」,
* 返回 1027 个字符,写进 varchar(1024) 会直接被数据库拒绝。</p>
*
* @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。
* <p>本表的写入不总在 HTTP 请求线程里(如超时作业触发自动审批时创建下一个任务),
* 此时 TokenData 为空,回落为 0,避免非空约束报错导致标题写入被静默吞掉。</p>
*
* @return 操作用户Id。
*/
private Long getOperatorId() {
TokenData tokenData = TokenData.takeFromRequest();
return tokenData == null || tokenData.getUserId() == null ? 0L : tokenData.getUserId();
}
}

212
common/common-flow/src/main/java/apelet/common/flow/service/impl/FlowTaskTitleBackfillServiceImpl.java

@ -0,0 +1,212 @@ @@ -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<FlowWorkOrder> 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<String> taskKeySet = this.getTaskKeySet(processInstanceId);
if (CollUtil.isEmpty(taskKeySet)) {
return;
}
Map<String, FlowTaskInstanceExt> 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);
}
}
}
/**
* 按模板重新解析该节点的标题。
* <p>模板必须用实例自己的 processDefinitionId 取 —— zz_flow_task_ext 的主键是
* (process_definition_id, task_id),老实例指向当初发布的那一版定义,用当前主版本会取到改版后的模板。</p>
*
* @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);
}
/**
* 取该实例出现过的所有节点:已办的历史任务 + 正在待办的任务。
* <p>待办节点也要带上,否则老单据在待办列表里同样没有标题。</p>
*
* @param processInstanceId 流程实例Id。
* @return 节点Id集合。
*/
private Set<String> getTaskKeySet(String processInstanceId) {
Set<String> taskKeySet = new HashSet<>();
List<HistoricTaskInstance> historicTaskList =
historyService.createHistoricTaskInstanceQuery().processInstanceId(processInstanceId).list();
for (HistoricTaskInstance task : historicTaskList) {
if (StrUtil.isNotBlank(task.getTaskDefinitionKey())) {
taskKeySet.add(task.getTaskDefinitionKey());
}
}
List<Task> 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);
}
}

40
common/common-flow/src/main/java/apelet/common/flow/service/impl/FlowWorkOrderServiceImpl.java

@ -17,6 +17,7 @@ import apelet.common.flow.model.FlowWorkOrder; @@ -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<FlowWorkOrder, Long> i @@ -61,6 +62,8 @@ public class FlowWorkOrderServiceImpl extends BaseService<FlowWorkOrder, Long> i
@Autowired
private FlowEntryService flowEntryService;
@Autowired
private FlowTaskInstanceExtService flowTaskInstanceExtService;
@Autowired
private CommonRedisUtil commonRedisUtil;
/**
@ -318,35 +321,15 @@ public class FlowWorkOrderServiceImpl extends BaseService<FlowWorkOrder, Long> i @@ -318,35 +321,15 @@ public class FlowWorkOrderServiceImpl extends BaseService<FlowWorkOrder, Long> i
new LambdaQueryWrapper<FlowWorkOrderExt>().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<FlowWorkOrder> getListByWorkOrderIdAfter(Long cursorWorkOrderId, int limit) {
LambdaQueryWrapper<FlowWorkOrder> 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<FlowWorkOrder, Long> i @@ -357,6 +340,9 @@ public class FlowWorkOrderServiceImpl extends BaseService<FlowWorkOrder, Long> i
return r;
}
flowWorkOrderMapper.deleteById(flowWorkOrder.getWorkOrderId());
// 工单是逻辑删除,act_hi_taskinst 里的任务行仍在;节点扩展若不跟着删,
// 会搜出一张已删除的单据,而列表里那行又没有标题。
flowTaskInstanceExtService.deleteByProcessInstanceId(flowWorkOrder.getProcessInstanceId());
return CallResult.ok();
}

75
common/common-flow/src/main/java/apelet/common/flow/vo/FlowTaskTitleBackfillVo.java

@ -0,0 +1,75 @@ @@ -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;
}
Loading…
Cancel
Save