从本篇文章开始,演示如何在一个业务系统(这里是 OA)中嵌入 Activiti,并实现相关业务流程的审批流转。
准备工作
这里作为演示用的业务系统是我开发的一个很常见的员工管理系统,该系统使用 Vue+ElementPlus 做前端,Spring Boot 做后端。项目地址:
-
。
该项目的前端使用 Cursor 开发,关于 Cursor 可以阅读我的。
下载该项目后需要导入数据库表结构和数据,并配置连接 Redis 和数据库。前端页面需要使用 NodeJS 的包管理工具 NPM 启动,这里不再赘述。
该项目已经包含必要的菜单管理、部门管理和员工管理等基本模块,因此这里只需要完善用 Activiti 实现人资系统相关审批业务的部分。
该项目的菜单展示和接口调用都有权限控制,如果返回状态 401 或看不到菜单说明缺少权限,应当使用有管理员权限的帐号,数据库中预设的管理员账号是
15651001234
。
先整合 Activiti 依赖到演示项目中,具体过程可以参考。
为了方便调用 Activiti 的 API,引入一个我编写的工具集合:
<dependency>
<groupId>cn.icexmoon</groupId>
<artifactId>activiti-util</artifactId>
<version>1.0.1</version>
</dependency>
流程管理
为系统添加一个流程管理页面,用于上传和管理系统中的审批流。
分页查询
用 Activiti API 实现不包含时间条件的分页查询:
public Page<ProcessDefinitionDTO> page(Long pageNum, Long pageSize, String key, String processDefinitionName) {
ProcessDefinitionQuery processDefinitionQuery = repositoryService.createProcessDefinitionQuery();
if (!StrUtil.isEmpty(key)) {
processDefinitionQuery.processDefinitionKeyLike(key);
}
if (!StrUtil.isEmpty(processDefinitionName)) {
processDefinitionQuery.processDefinitionNameLike(processDefinitionName);
}
int offset = (int)((pageNum - 1) * pageSize);
List<ProcessDefinition> processDefinitions = processDefinitionQuery
.orderByProcessDefinitionVersion().desc()
.listPage(offset, pageSize.intValue());
long count = repositoryService.createProcessDefinitionQuery().count();
Page<ProcessDefinitionDTO> page = new Page<>(pageNum, pageSize);
List<ProcessDefinitionDTO> dtos = getProcessDefinitionDTOS(processDefinitions);
page.setRecords(dtos);
page.setTotal(count);
return page;
}
需要注意的是,Activiti 的分页查询方法为listPage
,该方法并不像通常的 API 设计那样,接收的参数并不是页码和页宽,而是数据库游标的初始索引(offset)和返回条数(limit)。
此外,查询到的结果集List<ProcessDefinition>
不能直接 JSON 后返回给前端,因为ProcessDefinition
中包含敏感信息,添加了限制,直接 JSON 化会报错。因此这里引入了一个流程定义的 DTO 用于返回给前端:
public class ProcessDefinitionDTO {
private String id;
private String deploymentId;
private String deploymentName;
private String name;
private String key;
private int version;
pattern = "yyyy-MM-dd HH:mm:ss")
( private Date deploymentTime;
private String resourceName;
private String diagramResourceName;
}
Activiti 的 API 并不支持直接查询某个时间段内部署的流程定义,因此需要使用自定义 Mapper 进行查询。
添加一个 Mybatis 的 Mapper 接口:
public interface ActivitiCustomMapper {
/**
* 查询流程定义
*
* @param start 流程部署开始时间
* @param end 流程部署结束时间
* @param offset 游标
* @param limit 返回数据条数
* @param key 流程定义key
* @param processDefinitionName 流程定义名称
* @param deploymentName 部署名称
* @return 流程定义集合
*/
List<ProcessDefinition> customSelectProcessDefinitions(LocalDateTime start, LocalDateTime end, int offset, int limit, String key, String processDefinitionName, String deploymentName);
/**
* 查询流程定义数目
*
* @param start 流程部署开始时间
* @param end 流程部署结束时间
* @param key 流程定义key
* @param processDefinitionName 流程定义名称
* @param deploymentName 流程部署名称
* @return 流程定义集合
*/
Long customCountProcessDefinitions(LocalDateTime start, LocalDateTime end, String key, String processDefinitionName, String deploymentName);
}
编写对应的 XML:
<mapper namespace="cn.icexmoon.oaservice.mapper.ActivitiCustomMapper">
<resultMap id="processDefinition" type="org.activiti.engine.impl.persistence.entity.ProcessDefinitionEntityImpl">
<id property="id" column="ID_" />
<result property="version" column="REV_"/>
<result property="category" column="CATEGORY_"/>
<result property="name" column="NAME_"/>
<result property="key" column="KEY_"/>
<result property="version" column="VERSION_"/>
<result property="deploymentId" column="DEPLOYMENT_ID_"/>
<result property="description" column="DESCRIPTION_"/>
<result property="resourceName" column="RESOURCE_NAME_"/>
<result property="diagramResourceName" column="DGRM_RESOURCE_NAME_"/>
</resultMap>
<select id="customSelectProcessDefinitions" resultMap="processDefinition">
SELECT PD.*
FROM ACT_RE_PROCDEF PD
JOIN ACT_RE_DEPLOYMENT DEP ON PD.DEPLOYMENT_ID_ = DEP.ID_
WHERE 1=1
<if test="start != null">
AND DEP.DEPLOY_TIME_ >= #{start}
</if>
<if test="end != null">
AND DEP.DEPLOY_TIME_ <= #{end}
</if>
<if test="key != null and '' != key">
AND PD.KEY_ LIKE CONCAT('%',#{key},'%')
</if>
<if test="processDefinitionName != null and '' != processDefinitionName">
AND PD.NAME_ LIKE CONCAT('%',#{processDefinitionName},'%')
</if>
<if test="deploymentName != null and '' != deploymentName">
AND DEP.NAME_ LIKE CONCAT('%',#{deploymentName},'%')
</if>
ORDER BY DEP.DEPLOY_TIME_ DESC
LIMIT #{limit} OFFSET #{offset}
</select>
<select id="customCountProcessDefinitions" resultType="long">
SELECT COUNT(1)
FROM ACT_RE_PROCDEF PD
JOIN ACT_RE_DEPLOYMENT DEP ON PD.DEPLOYMENT_ID_ = DEP.ID_
WHERE 1=1
<if test="start != null">
AND DEP.DEPLOY_TIME_ >= #{start}
</if>
<if test="end != null">
AND DEP.DEPLOY_TIME_ <= #{end}
</if>
<if test="key != null and '' != key">
AND PD.KEY_ LIKE CONCAT('%',#{key},'%')
</if>
<if test="processDefinitionName != null and '' != processDefinitionName">
AND PD.NAME_ LIKE CONCAT('%',#{processDefinitionName},'%')
</if>
<if test="deploymentName != null and '' != deploymentName">
AND DEP.NAME_ LIKE CONCAT('%',#{deploymentName},'%')
</if>
</select>
</mapper>
部署位置在
resources/mapper/ActivitiCustomMapper.xml
在配置中将自定义 Mapper 接口和 XML “告诉” Activiti:
spring
activiti
custom-mybatis-xml-mappers
mapper/ActivitiCustomMapper.xml custom-mybatis-mappers
cn.icexmoon.oaservice.mapper.ActivitiCustomMapper
注意,这里指定 XML 时不能使用通常的
classpath*:mapper/...
这样的方式,否则会报错。
如果配置没有问题,此时重启应用 Activiti 就会加载相应的 Mapper。
Activiti 提供一个ManagementService
,可以用于执行自定义 Mapper 调用:
public class ProcessDefinitionServiceImpl implements ProcessDefinitionService {
private ManagementService managementService;
// ...
}
封装对应的调用:
private long getTotal(Date start, Date end, String key, String processDefinitionName, String deploymentName) {
long total = managementService.executeCustomSql(new AbstractCustomSqlExecution<ActivitiCustomMapper, Long>(ActivitiCustomMapper.class) {
public Long execute(ActivitiCustomMapper activitiCustomMapper) {
return activitiCustomMapper.customCountProcessDefinitions(TimeUtils.toStartTime(start), TimeUtils.toEndTime(end), key, processDefinitionName, deploymentName);
}
});
return total;
}
private List<ProcessDefinition> getProcessDefinitions(Date start, Date end, int offset, int Limit, String key, String processDefinitionName, String deploymentName) {
List<ProcessDefinition> processDefinitions = managementService.executeCustomSql(new AbstractCustomSqlExecution<ActivitiCustomMapper, List<ProcessDefinition>>(ActivitiCustomMapper.class) {
public List<ProcessDefinition> execute(ActivitiCustomMapper activitiCustomMapper) {
return activitiCustomMapper.customSelectProcessDefinitions(TimeUtils.toStartTime(start), TimeUtils.toEndTime(end), offset, Limit, key, processDefinitionName, deploymentName);
}
});
return processDefinitions;
}
这里使用了一个我自己编写的时间工具类。
利用自定义 Mapper 实现查询指定时间段内部署的流程定义:
public Page<ProcessDefinitionDTO> page(Long pageNum, Long pageSize, String key, String processDefinitionName, String deploymentName, Date start, Date end) {
// 如果没有流程部署相关查询条件,通过其它API查询
if (StrUtil.isEmpty(deploymentName) && start == null && end == null) {
return page(pageNum, pageSize, key, processDefinitionName);
}
// 查询流程部署
// 执行自定义 Mapper 查询
int offset = (int) ((pageNum - 1) * pageSize);
int Limit = pageSize.intValue();
List<ProcessDefinition> processDefinitions = getProcessDefinitions(start, end, offset, Limit, key, processDefinitionName, deploymentName);
long total = getTotal(start, end, key, processDefinitionName, deploymentName);
Page<ProcessDefinitionDTO> page = new Page<>(pageNum, pageSize);
page.setTotal(total);
List<ProcessDefinitionDTO> dtos = getProcessDefinitionDTOS(processDefinitions);
page.setRecords(dtos);
return page;
}
这里通过 Activiti 外挂自定义 Mapper 的方式查询也是一种无奈的选择,虽然通过各种检索,都显示 DeploymentQuery 包含相应的查询部署时间的方法,但实际查看 Activiti 8.7 并没有该方法,且 Github 项目页面显示该类源码有5年没有变更了。这我真是百思不得其解。
除了这种很麻烦的外挂自定义 Mapper,更简单的方式是直接用项目引入的 Mybatis 操作 Activiti 表进行查询。大概外挂的方式可以更委婉的表明开发者并没有完全破坏 Activiti 封装。
新增
新增流程定义需要前端提供一个 BPMN2 文件和对应的 PNG 文件。
Controller 层接口:
"/add")
(public Result<Void> add( ("bpmnFile") MultipartFile bpmnFile,
"pngFile") MultipartFile pngFile,
( String name) {
try {
return processDefinitionService.add(bpmnFile, pngFile, name);
} catch (IOException e) {
e.printStackTrace();
return Result.fail("系统出错,请稍后再试");
}
}
参数 name 为部署名称(Deployment Name)。
Service 层:
public Result<Void> add(MultipartFile bpmnFile, MultipartFile pngFile, String name) throws IOException {
if (bpmnFile == null || pngFile == null) {
return Result.fail("必须包含 BPMN2 文件以及对应的 PNG 文件");
}
String bpmn2FileOriginalFilename = bpmnFile.getOriginalFilename();
String pngFileOriginalFilename = pngFile.getOriginalFilename();
if (StrUtil.isEmpty(bpmn2FileOriginalFilename)
|| StrUtil.isEmpty(pngFileOriginalFilename)) {
return Result.fail("文件名不能为空");
}
if (!bpmn2FileOriginalFilename.endsWith(BPMN2_SUFFIX)) {
return Result.fail("bpmn2 文件必须以 " + BPMN2_SUFFIX + " 为后缀名");
}
if (!pngFileOriginalFilename.endsWith(PNG_SUFFIX)) {
return Result.fail("图片文件必须以 " + PNG_SUFFIX + " 为后缀名");
}
// PNG 文件名必须与 BPMN2 文件名一致
String bpmn2SubName = bpmn2FileOriginalFilename.substring(0, bpmn2FileOriginalFilename.length() - BPMN2_SUFFIX.length());
String pngSubName = pngFileOriginalFilename.substring(0, pngFileOriginalFilename.length() - PNG_SUFFIX.length());
if (StrUtil.isEmpty(bpmn2SubName) || !bpmn2SubName.equals(pngSubName)) {
return Result.fail("BPMN2 文件名必须与 PNG 文件名一致");
}
// 添加流程定义
Deployment deploy = repositoryService.createDeployment()
.name(name)
.addInputStream(bpmn2FileOriginalFilename, bpmnFile.getInputStream())
.addInputStream(pngFileOriginalFilename, pngFile.getInputStream())
.deploy();
log.info("流程[%s]已部署".formatted(deploy.getId()));
return Result.success();
}
流程添加调用很简单,只是添加了一些前置入参合法性验证。
下载流程定义文件
流程定义文件(BPMN2 和 PNG)需要能从前端下载查看。
Controller:
"/file")
(public ResponseEntity<InputStreamResource> file( String deploymentId, String fileName) throws IOException {
InputStream inputStream = processDefinitionService.getResource(deploymentId, fileName);
// 输出文件
// 2. 包装为可下载资源
InputStreamResource resource = new InputStreamResource(inputStream);
// 3. 设置响应头(关键步骤)
HttpHeaders headers = new HttpHeaders();
headers.add(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=" + fileName);
headers.setContentType(MediaType.APPLICATION_OCTET_STREAM);
return ResponseEntity.ok()
.headers(headers)
.contentLength(inputStream.available())
.body(resource);
}
删除
public Result<Void> delete(String deploymentId, Boolean force) {
if (BooleanUtil.isTrue(force)) {
repositoryService.deleteDeployment(deploymentId, true);
} else {
try {
repositoryService.deleteDeployment(deploymentId);
} catch (PersistenceException e) {
// 存在没有完成审批的流程实例,不能删除
return Result.fail("该审批流有申请未完成审批");
}
}
return Result.success();
}
删除流程定义本身很简单,只是需要注意存在未审批完的流程实例时需要级联删除,这里用参数force
控制是否采用级联删除(将对应的流程实例也删除)。
本文的完整示例代码见。
The End.
文章评论