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> resultList = new LinkedList<>(); pageData.getDataList().forEach(instance -> resultList.add(BeanUtil.beanToMap(instance))); List taskInstanceList = pageData.getDataList(); @@ -842,11 +850,9 @@ public class FlowOperationController { flowWorkOrderService.getInList(INSTANCE_ID, instanceIdSet); 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(instanceIdSet); Set processDefinitionIdSet = taskInstanceList.stream() .map(HistoricTaskInstance::getProcessDefinitionId).collect(Collectors.toSet()); List flowEntryPublishList = @@ -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 { * @param processDefinitionName 流程名。 * @param beginDate 流程发起开始时间。 * @param endDate 流程发起结束时间。 + * @param title 首节点标题关键字,模糊匹配。 * @param pageParam 分页对象。 * @return 查询结果应答。 */ @@ -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 pageData = flowApiService.getHistoricProcessInstanceList( - null, processDefinitionName, loginName, beginDate, endDate, pageParam, true); + null, processDefinitionName, loginName, beginDate, endDate, title, pageParam, true); Set loginNameSet = pageData.getDataList().stream() .map(HistoricProcessInstance::getStartUserId).collect(Collectors.toSet()); List userInfoList = flowCustomExtFactory @@ -938,11 +945,8 @@ public class FlowOperationController { flowWorkOrderService.getInList(INSTANCE_ID, instanceIdSet); 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(instanceIdSet); Set processDefinitionIdSet = pageData.getDataList().stream() .map(HistoricProcessInstance::getProcessDefinitionId).collect(Collectors.toSet()); Map flowEntryPublishMap = new HashMap<>(); @@ -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 { @MyRequestBody String endDate, @MyRequestBody(required = true) MyPageParam pageParam) throws ParseException { MyPageData pageData = flowApiService.getHistoricProcessInstanceList( - null, processDefinitionName, startUser, beginDate, endDate, pageParam, false); + // listAllHistoricProcessInstance 是管理端「所有历史流程」,本次不开放标题搜索,沿用原行为 + null, processDefinitionName, startUser, beginDate, endDate, null, pageParam, false); List> resultList = new LinkedList<>(); pageData.getDataList().forEach(instance -> resultList.add(BeanUtil.beanToMap(instance))); List unfinishedProcessInstanceIds = pageData.getDataList().stream() @@ -1309,4 +1314,33 @@ public class FlowOperationController { } return null; } + + /** + * 回填节点标题(运维接口)。 + *

改造前节点标题是查询时实时解析的、从不落库,所以存量流程实例没有标题数据。本接口按 + * 「流程实例 × 节点」重新解析一遍并落到 zz_flow_task_instance_ext。

+ *

免鉴权({@link NoAuthInterface}),方便直接在服务器上 curl。因此做了这些防护:

+ *
    + *
  • 幂等 —— 已有标题行的 (实例, 节点) 直接跳过,重复调用不会产生重复数据,也不会覆盖已生成的值;
  • + *
  • 只处理在线表单流程(路由表单本来没有标题),且只处理工单仍存在的实例;
  • + *
  • 单条失败只记数不中断,可反复重跑补齐;
  • + *
  • 按游标分批,避免一次请求跑太久。
  • + *
+ *

用法:先 dryRun=true 看体量,再去掉 dryRun 正式跑;返回的 finished 为 true 表示已跑完, + * 否则把 nextCursor 带上再调一次。

+ * + * @param cursorWorkOrderId 游标,取工单Id大于该值的记录;不传表示从头开始。 + * @param batchSize 单批工单数,默认 200,上限 2000。 + * @param dryRun 为 true 时只统计不写库。 + * @return 本批执行结果。 + */ + @NoAuthInterface + @PostMapping("/backfillTaskTitle") + public ResponseResult backfillTaskTitle( + @RequestParam(required = false) Long cursorWorkOrderId, + @RequestParam(required = false) Integer batchSize, + @RequestParam(required = false) Boolean dryRun) { + return ResponseResult.success( + flowTaskTitleBackfillService.backfill(cursorWorkOrderId, batchSize, dryRun)); + } } diff --git a/common/common-flow/src/main/java/apelet/common/flow/dao/FlowTaskInstanceExtMapper.java b/common/common-flow/src/main/java/apelet/common/flow/dao/FlowTaskInstanceExtMapper.java new file mode 100644 index 0000000..943db4e --- /dev/null +++ b/common/common-flow/src/main/java/apelet/common/flow/dao/FlowTaskInstanceExtMapper.java @@ -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 { + + /** + * 按 (process_instance_id, task_key) 唯一键写入节点扩展,已存在则更新。 + *

用 ON DUPLICATE KEY UPDATE 而非「先查后写」,是为了让并发的多个任务创建落到同一条 SQL 上, + * 避免读-改-写之间互相覆盖,也天然保证同一节点只有一行。

+ * + * @param flowTaskInstanceExt 节点扩展对象。 + */ + void upsert(FlowTaskInstanceExt flowTaskInstanceExt); +} diff --git a/common/common-flow/src/main/java/apelet/common/flow/dao/mapper/FlowTaskInstanceExtMapper.xml b/common/common-flow/src/main/java/apelet/common/flow/dao/mapper/FlowTaskInstanceExtMapper.xml new file mode 100644 index 0000000..9cfe484 --- /dev/null +++ b/common/common-flow/src/main/java/apelet/common/flow/dao/mapper/FlowTaskInstanceExtMapper.xml @@ -0,0 +1,31 @@ + + + + + + + + + + + + + + + + + + + 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) + + diff --git a/common/common-flow/src/main/java/apelet/common/flow/listener/FlowUserTaskListener.java b/common/common-flow/src/main/java/apelet/common/flow/listener/FlowUserTaskListener.java index e35e5bd..a8f7a08 100644 --- a/common/common-flow/src/main/java/apelet/common/flow/listener/FlowUserTaskListener.java +++ b/common/common-flow/src/main/java/apelet/common/flow/listener/FlowUserTaskListener.java @@ -41,9 +41,11 @@ public class FlowUserTaskListener implements TaskListener { } /** - * 任务创建时按该节点配置的标题模板生成标题,写入工单扩展的 title_json,查询时直接读取。 + * 任务创建时按该节点配置的标题模板生成标题,写入 zz_flow_task_instance_ext,查询时直接读取。 *

首节点的 create 事件发生在工单创建之前(工单在 flowApiService.start 返回之后才建),此处取不到工单, - * 交由 FlowWorkOrderService#saveNew 在建单后补写,所以这里直接返回。

+ * 交由 FlowWorkOrderService#saveNew 在建单后按起始节点补写,所以这里直接返回。

+ * + * @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 listQuery, NativeQuery 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; +}