红茶的个人站点

  • 首页
  • 专栏
  • 开发工具
  • 其它
  • 隐私政策
Awalon
Talk is cheap,show me the code.
  1. 首页
  2. 未分类
  3. 正文

Activiti 学习笔记 4:OA

2025年6月5日 8点热度 0人点赞 0条评论

从本篇文章开始,演示如何在一个业务系统(这里是 OA)中嵌入 Activiti,并实现相关业务流程的审批流转。

准备工作

这里作为演示用的业务系统是我开发的一个很常见的员工管理系统,该系统使用 Vue+ElementPlus 做前端,Spring Boot 做后端。项目地址:

  • learn-cursor/ch1。

该项目的前端使用 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>

流程管理

为系统添加一个流程管理页面,用于上传和管理系统中的审批流。

image-20250605160519984

分页查询

用 Activiti API 实现不包含时间条件的分页查询:

@Override
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 用于返回给前端:

@Data
public class ProcessDefinitionDTO {
    private String id;
    private String deploymentId;
    private String deploymentName;
    private String name;
    private String key;
    private int version;
    @JsonFormat(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:

<?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="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_ &gt;= #{start}
        </if>
        <if test="end != null">
            AND DEP.DEPLOY_TIME_ &lt;= #{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_ &gt;= #{start}
        </if>
        <if test="end != null">
            AND DEP.DEPLOY_TIME_ &lt;= #{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 调用:

@Service
@Log4j2
public class ProcessDefinitionServiceImpl implements ProcessDefinitionService {
    @Autowired
    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) {
        @Override
        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) {
        @Override
        public List<ProcessDefinition> execute(ActivitiCustomMapper activitiCustomMapper) {
            return activitiCustomMapper.customSelectProcessDefinitions(TimeUtils.toStartTime(start), TimeUtils.toEndTime(end), offset, Limit, key, processDefinitionName, deploymentName);
        }
    });
    return processDefinitions;
}

这里使用了一个我自己编写的时间工具类TimeUtils。

利用自定义 Mapper 实现查询指定时间段内部署的流程定义:

@Override
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 文件。

image-20250605163753384

Controller 层接口:

@PostMapping("/add")
public Result<Void> add(@RequestParam("bpmnFile") MultipartFile bpmnFile,
                        @RequestParam("pngFile") MultipartFile pngFile,
                        @RequestParam String name) {
    try {
        return processDefinitionService.add(bpmnFile, pngFile, name);
    } catch (IOException e) {
        e.printStackTrace();
        return Result.fail("系统出错,请稍后再试");
    }
}

参数 name 为部署名称(Deployment Name)。

Service 层:

@Override
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)需要能从前端下载查看。

image-20250605163856963

Controller:

@GetMapping("/file")
public ResponseEntity<InputStreamResource> file(@RequestParam String deploymentId, @RequestParam 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);
}

删除

@Override
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.

本作品采用 知识共享署名 4.0 国际许可协议 进行许可
标签: activiti mybatis
最后更新:2025年6月5日

魔芋红茶

加一点PHP,加一点Go,加一点Python......

点赞
< 上一篇

文章评论

razz evil exclaim smile redface biggrin eek confused idea lol mad twisted rolleyes wink cool arrow neutral cry mrgreen drooling persevering
取消回复

*

code

COPYRIGHT © 2021 icexmoon.cn. ALL RIGHTS RESERVED.
本网站由提供CDN加速/云存储服务

Theme Kratos Made By Seaton Jiang

宁ICP备2021001508号

宁公网安备64040202000141号