前言:本文基于若依前后端分离版本(Spring Boot 3.3.0 + Vue 3 + Activiti 8.1.0)进行改造,相关教程可以在网上找到。在撰写此博客期间,笔者刚刚开始接触 Java Web,本系列下的文章内容包含大量“个人初期”视角,注意鉴别。

概述

Activiti8 是一个强大的流程管理工具,可以实现流程相关的业务,包括申请、任务发放、审批等操作。通过 Spring Boot 的 Security 进行鉴权,能够实现流程的部署、激活、挂起等功能。其核心目的是自动化实现流程图中的各个节点,对节点任务进行监控以及流程的执行。在流程测试员环节,可以规定由哪些用户或用户组负责,这些用户登录系统后就能看到当前需要处理的任务。

权限要求

Activiti7 及以上版本对权限有严格要求,主要涉及 GROUP_、ROLE_ACTIVITI_USER 等字段。需要将所有登录用户划分到对应的组内,暂时用 post_code(岗位代码)表示。例如,HR 人力资源部门需要添加 GROUP_HR 权限,也可以根据实际的部门(如 dept_code)进行权限划分。同时,所有用户必须拥有后一个权限,否则无法应用 Activiti 相关功能。

若依代码修改(添加权限)

若依本身实现了角色主导的权限控制,规定角色可以访问的内容,对每个用户赋予一个角色。若依原有的权限列表 permissions,规定了对一些菜单的增删改查权限。另一部分是修改代码后新添加的权限字段,基于角色和用户组的权限。

UserDetailsServiceImpl.java

1
2
3
4
5
6
7
8
public UserDetails createLoginUser(SysUser user)
{
Set<String> postCode = sysPostService.selectPostCodeByUserId(user.getUserId());
postCode = postCode.parallelStream().map( s -> "GROUP_" + s).collect(Collectors.toSet());
postCode.add("ROLE_ACTIVITI_USER");
List<SimpleGrantedAuthority> collect = postCode.stream().map(s -> new SimpleGrantedAuthority(s)).collect(Collectors.toList());
return new LoginUser(user.getUserId(), user.getDeptId(), user, permissionService.getMenuPermission(user), collect);
}

当然,上述代码中的用户组就可以根据自己的需求去改了,记得修改mapper、service之类的。

LoginUser.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
/**
* 权限列表
*/
private Set<String> permissions;
private List<SimpleGrantedAuthority> authorities;
// other code
public LoginUser(Long userId, Long deptId, SysUser user, Set<String> permissions, List<SimpleGrantedAuthority> authorities) {
this.userId = userId;
this.deptId = deptId;
this.user = user;
this.permissions = permissions;
this.authorities = authorities;
}
// other code
@Override
public Collection<? extends GrantedAuthority> getAuthorities()
{
return authorities;
}

最终,登录用户的信息应该是这样的:

数据库建表

由于 Activiti8 某个表中的字段与 MySQL 的一个类型会起冲突,需要手动添加表。其他表在初始化的时候会自动创建,跟着网上的其他帖子做就可以。以下是创建 act_hi_identitylink 表的 SQL 语句(据说更高版本的activiti修复了这个BUG):

1
2
3
4
5
6
7
8
9
CREATE TABLE `act_hi_identitylink` ( 
`ID_` varchar(64) COLLATE utf8_bin NOT NULL,
`GROUP_ID_` varchar(255) COLLATE utf8_bin DEFAULT NULL,
`TYPE_` varchar(255) COLLATE utf8_bin DEFAULT NULL,
`USER_ID_` varchar(255) COLLATE utf8_bin DEFAULT NULL,
`TASK_ID_` varchar(64) COLLATE utf8_bin DEFAULT NULL,
`PROC_INST_ID_` varchar(64) COLLATE utf8_bin DEFAULT NULL,
`DETAILS_` longblob, PRIMARY KEY (`ID_`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_bin

小插曲

到这一步按理说就可以正常使用activiti了,跟着网上的教程也应该是如此。但是在实际应用中遇到了另一个问题:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20

***************************
APPLICATION FAILED TO START
***************************

Description:

Field permitAllUrl in com.iams.framework.config.SecurityConfig required a single bean, but 2 were found:
- requestMappingHandlerMapping: defined by method 'requestMappingHandlerMapping' in class path resource [org/springframework/boot/autoconfigure/web/servlet/WebMvcAutoConfiguration$EnableWebMvcConfiguration.class]
- controllerEndpointHandlerMapping: defined by method 'controllerEndpointHandlerMapping' in class path resource [org/springframework/boot/actuate/autoconfigure/endpoint/web/servlet/WebMvcEndpointManagementContextConfiguration.class]

This may be due to missing parameter name information

Action:

Consider marking one of the beans as @Primary, updating the consumer to accept multiple beans, or using @Qualifier to identify the bean that should be consumed

Ensure that your compiler is configured to use the '-parameters' flag.
You may need to update both your build tool settings as well as your IDE.
(See https://github.com/spring-projects/spring-framework/wiki/Upgrading-to-Spring-Framework-6.x#parameter-name-retention)

鄙人初学者,啥也不懂,感觉大概意思是“我”需要一个bean,但是找到了两个同类型的,“我”不知道用哪个。一顿操作猛如虎,全靠GPT帮我写,找不到具体用哪个?直接指明就行!

PermitAllUrlProperties.java

1
2
3
4
5
6
private final RequestMappingHandlerMapping mapping;

@Autowired
public PermitAllUrlProperties(@Qualifier("requestMappingHandlerMapping") RequestMappingHandlerMapping mapping) {
this.mapping = mapping;
}

部署流程(通过 BPMN)

先不考虑如何进行前端传文件进行部署的方式,后端静态部署一个流程看看:

  1. 查询是否已经存在当前流程(BPMN 文件中的 process id=“borrowArchive” ):
1
2
3
long count = repositoryService.createProcessDefinitionQuery()
.processDefinitionKey(“borrowArchive”) // 替换为 BPMN文件中的process id,记住它,后面要用
.count();
  1. 如果不存在,就开始部署流程,传入名称与文件路径:
1
2
3
4
Deployment deployment = repositoryService.createDeployment()
.addClasspathResource(bpmnFilePath) // 加载文件(文件路径)
.name("档案借阅流程") // 流程名
.deploy();

emmmmm,虽然部署了,但我们怎么用他?我们怎么获得一些关于定义好的流程的信息?我们先得把他从部署的一大堆流程中拿出来再仔细揣摩它,activiti提供了一个查找全部已部署流程的接口:processDefinitionQuery。pageDomain是一个分页对象,具体是什么,不知道,先用着,以后慢慢了解。

1
2
3
4
5
6
7
8
9
10
import org.activiti.engine.RepositoryService;

// other code
@Autowired
private RepositoryService repositoryService;

// other code
ProcessDefinitionQuery processDefinitionQuery = repositoryService.createProcessDefinitionQuery().orderByProcessDefinitionId().orderByProcessDefinitionVersion().desc();
List<ProcessDefinition> processDefinitions = processDefinitionQuery.listPage((pageDomain.getPageNum() - 1) * pageDomain.getPageSize(), pageDomain.getPageSize());

在List后面打一个断点,debug一下,仔细(大概)看一下部署后的流程有哪些信息。

嫌麻烦?其实也还可以直接查数据库,毕竟数据库的字段都是要被拿来增删查改的!可以查看ACT_RE_PROCDEF表,大概是下面这个样子:

当然,如果还嫌麻烦,直接查看ProcessDefinition类的定义也行,熟悉代码的小伙伴一眼就明白。

知道有些啥东西就行(了吧),反正笔者也是初学者,知道在哪看怎么看就行,用到它的信息再回来仔细对比。

实例化(创建)流程

是时候发起一个流程了!也就是实例化,方法如下:其中的DefinitionKey一定要对应我们部署的流程,我们要发起哪个流程就靠它跟上文我们部署的流程进行对应了。可以看到这里还有一个withName,似乎并不是之前的流程名,而是具体的事例。对的,这里的Name是具体流程实例层面的,之前的Name是对整个流程的名字(讲道理,没跑代码,有些忘了)。

1
2
3
4
5
6
ProcessInstance processInstance = processRuntime.start(ProcessPayloadBuilder
.start()
.withProcessDefinitionKey(“borrowArchive”)
.withName(“借阅申请——张三”)
.withBusinessKey(id)
.build());

不过发起实例之后我们可以去数据库看看,这个实例有哪些字段。(我的另一个项目,两个流程,各发起了一个实例,两个流程都是两个任务的流程,而且我另一个项目中启动实例的时候并没有添加名字,所以此处的Name字段是空的)

另外还有一个比较有意思的地方,两个实例应该就两条数据,为什么这里是四个?而且明晃晃的PARENT_ID,也就是说如果遇到多任务的,会根据任务顺序串成层次表?

确实是,当然还有别的类型,先不管啦!

任务分发

每个流程的关节点一般需要进行审批等工作,bpmn 文件中会使用 activiti:candidateUsers=“${deptLeader}” 规定当前任务的候选人/候选组。实例化流程时会使用 withVariable(“deptLeader”, “ry”)(跟着上面的withName续写就行)指定处理该任务的候选人(似乎是会传一个参数进去保存,用户名?ID?实测下来是用户名)。

候选人(比如:ry)登录系统后,通过 taskRuntime.tasks(),调用当前用户信息查询对应任务,进行展示发送。代码上的流程其实跟查询部署的流程很像:
ActTaskServiceImpl.java(代码细节暂不深究,能跑就行!好像还跟security有关?)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import org.activiti.api.task.runtime.TaskRuntime;
// other code
@Autowired
private TaskRuntime taskRuntime;
// other code
@Override
public Page<ActTaskDTO> selectProcessDefinitionList(PageDomain pageDomain) {
Page<ActTaskDTO> list = new Page<ActTaskDTO>();
org.activiti.api.runtime.shared.query.Page<Task> pageTasks = taskRuntime.tasks(Pageable.of((pageDomain.getPageNum() - 1) * pageDomain.getPageSize(), pageDomain.getPageSize()));
List<Task> tasks = pageTasks.getContent();
int totalItems = pageTasks.getTotalItems();
list.setTotal(totalItems);
if (totalItems != 0) {
Set<String> processInstanceIdIds = tasks.parallelStream().map(t -> t.getProcessInstanceId()).collect(Collectors.toSet());
List<ProcessInstance> processInstanceList = runtimeService.createProcessInstanceQuery().processInstanceIds(processInstanceIdIds).list();
List<ActTaskDTO> actTaskDTOS = tasks.stream()
.map(t -> new ActTaskDTO(t, processInstanceList.parallelStream().filter(pi -> t.getProcessInstanceId().equals(pi.getId())).findAny().get()))
.collect(Collectors.toList());
list.addAll(actTaskDTOS);

}
return list;
}

其实也可以稍微看一下数据库中的表,初窥门径:ACT_RU_TASKACT_RU_IDENTITYLINK,分别标识当前任务、当前任务分配给的用户与组。

实例键与业务键

Activiti 实例化流程时会生成 ID 用于标记每一个流程实例,但业务键由用户提供,用于绑定唯一的业务实体。实例键由 Activiti 生成,用于标记每一个流程实例。如果业务键是唯一的,那么业务键和实例键都可以唯一指定一个流程实例。也可以不设置业务键,将 getId() 的结果绑定到业务实体上,但不推荐这样做。

任务执行

说审批任务,其实大多数情况下审批环节就两个字段:审批结果、备注。流程任务之间判断一些值的情况,比如BPMN文件中:

1
2
3
<sequenceFlow id="flow3" name="通过" sourceRef="decisionGateway" targetRef="approveEnd">
<conditionExpression xsi:type="tFormalExpression">${approvalResult == '通过'}</conditionExpression>
</sequenceFlow>

其中的approvalResult就是一个需要我们传入的值,如何将审批结果与传入流程的变量关联起来?我这里说一个不太合适的方式:把前端传来的键值对,全部注册成流程的变量,相当于我们把string a = "通过";传入一个判断语句,看看a是不是“通过”。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
@Override
public int formDataSave(String taskID, List<ActWorkflowFormDataDTO> awfs) throws ParseException {
Task task = taskRuntime.task(taskID);
// 处理流程变量
// 既然设置了表单,那么就把表单中所有的变量作为参数传入,只保留关键字段作为判断语句
Map<String, Object> variables = new HashMap<>();
for (ActWorkflowFormDataDTO awf : awfs) {
if ("i".equals(awf.getControlIsParam())) {
variables.put(awf.getControlId(), awf.getControlValue());
}
}
if (task.getAssignee() == null) {
taskRuntime.claim(TaskPayloadBuilder.claim()
.withTaskId(task.getId())
.build());
}

runtimeService.setVariables(task.getProcessInstanceId(), variables);

taskRuntime.complete(TaskPayloadBuilder.complete()
.withTaskId(taskID)
.withVariables(variables)
.build());
return 1;
}

上面这段代码基本就是把前端送来的键值对(现版本是固定的approvalResult:value1,approvalComment:value2),把他们添加到流程中,流程判断语句会判断value1是不是“通过”。

状态监听


待审核、已批准、未通过三种常见的状态需要实时传达给前端用户。现阶段的流程推进没有对数据库做任何更改,需要添加一个监听器,去修改数据库中的具体状态。流程会给状态 state 注入值,当任务走到这里时开始执行函数。比如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

public class ArchiveDestroyStateListener implements ExecutionListener {

private Expression state;
@Override
public void notify(DelegateExecution delegateExecution) {
String instanceId = delegateExecution.getProcessInstanceId();
String businessKey = delegateExecution.getProcessInstanceBusinessKey();
ArchiveDestroyApprovalMapper archiveDestroyApprovalMapper = SpringUtils.getBean(ArchiveDestroyApprovalMapper.class);
ArchiveDestroyApproval archiveDestroyApproval = archiveDestroyApprovalMapper.selectArchiveDestroyApprovalByInstanceIdAndBusinessKey(instanceId, businessKey);

archiveDestroyApproval.setStatus(state.getValue(delegateExecution).toString());
archiveDestroyApprovalMapper.updateArchiveDestroyApproval(archiveDestroyApproval);
}
}

通过当前的任务查询对应的业务记录(MySQL数据),修改业务记录的信息,前端就能进行展示了。