引言
审批流是工作流引擎最经典的应用场景,也是最容易被低估的场景。它的技术难点不在流程流转,而在「人」带来的不确定性:审批人可能离职、可能休假、可能同时被指派到十个任务、可能对同一个单据反复驳回重提。这些情况在测试环境里不会出现,上线后却天天发生。
更麻烦的是审批流的规则往往由业务方定义且频繁变化:「金额超过 10 万要加一级总监审批」「跨部门的申请要先部门负责人会签」「节假日顺延到工作日」——这些规则如果没有被引擎建模,就会变成散落在代码里的 if-else。
本文按「任务生命周期」的顺序展开:任务如何被创建与分配(候选人模型)、如何被认领与完成、会签与加签如何实现、驳回与撤回如何处理、表单如何与流程绑定、超时与催办如何设计、审批历史如何留痕。每一部分都给出 BPMN 与 Java 的可落地写法。想先看 BPMN 基础的读者,可以从 BPMN 2.0 与 Camunda 实战 开始。
目录
- 人工任务的技术难点
- 任务的分配模型
- 候选人与候选组的解析
- 认领、完成与释放
- 会签与或签的实现
- 加签、转办与委托
- 驳回、撤回与重新提交
- 表单的定义与流程绑定
- 表单数据的存储与校验
- 审批意见与附件
- SLA、催办与超时升级
- 审批历史与流程留痕
- 组织架构同步
- 通知渠道设计
- 批量审批与移动端
- 权限与数据可见性
- 落地路线图
- 权衡取舍
- 常见坑清单
- 小结
1. 人工任务的技术难点
自动任务的行为是确定的:调用、成功或失败。人工任务的行为有四类不确定性:
- 时间不确定:可能 5 秒完成,也可能 5 天不动。
- 人可能变:审批人离职、调岗、休假,指派关系需要动态解析。
- 决策可能反复:驳回后修改重提,形成多轮循环。
- 上下文相关:审批人需要看到足够的信息才能决策,涉及数据权限。
这四类不确定性决定了人工任务的设计要点:任务不能硬编码指派人(要用表达式或候选组);任务必须有超时与升级机制;驳回重提必须能回到正确的节点而不是从头开始;审批页面必须做数据权限控制。
还有一个容易被忽略的点:人工任务的「状态」比自动任务多。除了「待处理、已完成」,还有「已认领、已转办、已委托、已加签、已撤回」。这些状态如果没有被显式建模,运维查询就会变成一堆无法解释的数据。
2. 任务的分配模型
人工任务的分配有三种模型,对应不同的业务语义:
| 模型 | 语义 | BPMN 属性 | 适用场景 |
|---|---|---|---|
| 直接指派 | 指定唯一处理人 | camunda:assignee | 明确的责任人(如申请人直属上级) |
| 候选人 | 多人可认领,先到先得 | camunda:candidateUsers | 值班人员、任意一位客服 |
| 候选组 | 组内成员都可认领 | camunda:candidateGroups | 角色审批(财务、法务) |
<bpmn:userTask id="financeApprove" name="财务审批"
camunda:candidateGroups="ROLE_FINANCE"
camunda:dueDate="${dateTime().plusDays(2).toDate()}">
<bpmn:extensionElements>
<camunda:formData>
<camunda:formField id="approved" label="是否通过" type="boolean" />
<camunda:formField id="comment" label="审批意见" type="string" />
</camunda:formData>
</bpmn:extensionElements>
</bpmn:userTask>
实践中建议优先用候选组而不是直接指派。因为直接指派把「人」写进了流程定义,人员变动时就要改流程;候选组把「角色」写进流程,人员变动只需改组织架构数据。
3. 候选人与候选组的解析
候选组在运行时需要展开成具体的人。展开方式有三种:
- 引擎内置身份表:Camunda 的
ACT_ID_USER/ACT_ID_GROUP/ACT_ID_MEMBERSHIP表,适合小规模。 - 外部组织架构服务:通过自定义的
IdentityProvider或表达式调用外部接口,适合企业环境。 - 表达式计算:在流程定义里直接写表达式,比如
${approvalService.findApprovers(order)}。
@Component("approvalService")
public class ApprovalService {
private final OrgClient orgClient;
public List<String> findApprovers(Order order) {
// 金额决定审批层级,部门决定审批人
String level = order.getAmount().compareTo(new BigDecimal("100000")) > 0
? "DIRECTOR" : "MANAGER";
return orgClient.findByDepartmentAndRole(order.getDepartment(), level);
}
}
第三种最灵活但要注意「解析结果的稳定性」:如果每次查询任务列表时都重新解析,人员变动会让任务的处理人集合悄悄变化。建议在任务创建时把解析结果快照存下来,同时保留「重新解析」的运维入口。
4. 认领、完成与释放
候选任务的处理流程是「查询 → 认领 → 处理 → 完成」四步。认领(Claim)把任务从「候选池」变为「我的任务」,防止多人重复处理。
// 查询我的待办(已认领的 + 我所在的候选组未认领的)
List<Task> mine = taskService.createTaskQuery()
.taskAssignee(userId)
.or()
.taskCandidateGroupIn(myGroups)
.endOr()
.orderByTaskCreateTime().desc()
.listPage(0, 20);
// 认领:并发下只有一个成功
try {
taskService.claim(taskId, userId);
} catch (TaskAlreadyClaimedException e) {
throw new BizException("该任务已被他人认领");
}
claim 的并发安全由引擎保证(内部用乐观锁更新 ACT_RU_TASK.ASSIGNEE_)。前端要在用户点击「处理」时立即认领,而不是打开详情页就认领,否则会出现大量「占着任务不处理」的僵尸认领。
释放(Unclaim)用于「我认领了但处理不了」,把任务退回候选池。企业环境里更常见的需求是「转办」(直接指定新处理人),见第 6 节。
5. 会签与或签的实现
会签(所有人都要处理)与或签(任意一人处理即可)在 BPMN 里都用多实例实现,区别在完成条件。
<!-- 会签:全部完成才继续 -->
<bpmn:userTask id="countersign" name="部门会签">
<bpmn:multiInstanceLoopCharacteristics isSequential="false"
camunda:collection="${approvers}" camunda:elementVariable="approver">
<bpmn:completionCondition xsi:type="bpmn:tFormalExpression">
${nrOfCompletedInstances == nrOfInstances}
</bpmn:completionCondition>
</bpmn:multiInstanceLoopCharacteristics>
</bpmn:userTask>
<!-- 或签:一人通过即继续,一人驳回则整体驳回 -->
<bpmn:userTask id="orsign" name="值班审批">
<bpmn:multiInstanceLoopCharacteristics isSequential="false"
camunda:collection="${onDuty}" camunda:elementVariable="approver">
<bpmn:completionCondition xsi:type="bpmn:tFormalExpression">
${nrOfCompletedInstances >= 1}
</bpmn:completionCondition>
</bpmn:multiInstanceLoopCharacteristics>
</bpmn:userTask>
「一人驳回则整体驳回」在多实例里无法直接表达,标准做法是「完成条件用计数,驳回时用一个变量短路」:
// 任务完成监听器:驳回时立即让多实例结束
@Component("rejectListener")
public class RejectListener implements TaskListener {
@Override
public void notify(DelegateTask task) {
Boolean approved = (Boolean) task.getVariable("approved");
if (Boolean.FALSE.equals(approved)) {
task.setVariable("rejected", true);
// 把多实例的完成条件改为「已驳回即结束」
task.setVariable("nrOfInstances", task.getVariable("nrOfCompletedInstances"));
}
}
}
注意这里用到了引擎的内部变量 nrOfInstances,这是 Camunda 特有的技巧,升级版本时要回归测试。
6. 加签、转办与委托
这三个操作是多实例与指派关系之外的「人为干预」,BPMN 规范里没有标准元素,需要自己实现。
- 加签:在当前节点增加一个审批人。前置加签(在我之前审)、后置加签(在我之后审)。
- 转办:把任务永久转给他人,我从此不再有该任务。
- 委托:把任务临时委托他人处理,处理结果仍记在我名下,我保留查看权。
Camunda 的 delegateTask 与 setAssignee 分别对应委托与转办:
// 转办:直接改指派人
taskService.setAssignee(taskId, newUserId);
// 委托:保留原 owner,新人为 assignee
taskService.delegateTask(taskId, delegateUserId);
// 被委托人处理完后,归还给原 owner
taskService.resolveTask(taskId);
加签需要创建新任务并调整流程。在 Camunda 里可以用 Process Instance Modification API 在当前节点前插入一个新任务,或者用「并行网关 + 多实例」在建模阶段预留加签位。前者灵活但复杂,后者简单但受限,建议按业务的实际频率选择。
7. 驳回、撤回与重新提交
驳回是最容易做错的环节。三种常见的驳回语义:
| 语义 | 行为 | 实现 |
|---|---|---|
| 驳回到发起人 | 回到第一个节点,重新走全流程 | 用 Process Instance Modification 跳回起始节点 |
| 驳回到上一节点 | 回到前一审批人 | 记录节点历史,跳回指定活动 |
| 驳回并结束 | 流程终止为「已驳回」 | 直接结束实例并置业务状态 |
// 驳回到上一节点:先结束当前任务,再激活历史节点
runtimeService.createProcessInstanceModification(instanceId)
.cancelAllForActivity(currentActivityId)
.startBeforeActivity(targetActivityId)
.setAnnotation("驳回至 " + targetActivityId)
.execute();
撤回是发起人的特权:在审批未完成前把流程拉回。它的实现与驳回类似,但约束更严:一旦有任何审批人处理过,撤回应该被禁止(否则审批记录会失去意义)。
重新提交要复用原流程实例还是新建实例?两种做法各有优劣:复用原实例能保留完整历史(包括被驳回的那几轮),新建实例则简单但要手工关联。推荐复用原实例,并把每一轮提交记录在业务表里,形成「轮次」概念。
8. 表单的定义与流程绑定
审批表单有三种实现方式:
- 引擎内置表单(Camunda Forms / FormData):定义在 BPMN 里,简单但表达能力有限。
- 外部表单引擎(Form.io、form-create):表单独立定义与版本化,流程通过 key 引用。
- 前端自研:完全自定义,最灵活但工作量最大。
Camunda 8 的表单定义是独立的 JSON,通过 formKey 与任务关联:
{
"schemaVersion": 3,
"components": [
{"type": "textfield", "key": "applicant", "label": "申请人", "readonly": true},
{"type": "number", "key": "amount", "label": "申请金额", "validate": {"min": 0}},
{"type": "textarea", "key": "reason", "label": "申请事由",
"validate": {"required": true, "maxLength": 500}},
{"type": "checkbox", "key": "approved", "label": "同意"}
]
}
表单与流程绑定时要区分两类字段:流程变量(影响流转,比如 amount 决定走哪条分支)与业务字段(只做记录,比如 reason)。前者必须写进流程变量,后者建议只存业务表,避免流程变量膨胀。
9. 表单数据的存储与校验
表单数据的存储有两个层次:流程变量(供引擎做分支判断)与业务表(供查询与报表)。
CREATE TABLE approval_form (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
process_id VARCHAR(64) NOT NULL,
task_id VARCHAR(64) NULL,
form_key VARCHAR(64) NOT NULL,
form_version INT NOT NULL,
data JSON NOT NULL,
submitted_by VARCHAR(64) NOT NULL,
submitted_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
KEY idx_process (process_id)
);
form_version 很关键:表单定义会随流程版本演进,如果只存数据不存版本,历史记录就无法正确渲染(比如表单删了一个字段,老数据渲染会报错)。
校验要在三层做:前端(即时反馈)、后端提交时(防绕过)、流程流转时(关键字段的最终校验)。前端的校验永远不可信,因为接口可以被直接调用。
10. 审批意见与附件
审批意见是留痕的核心,至少要记录「谁、何时、什么意见、同意还是拒绝」。
CREATE TABLE approval_comment (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
process_id VARCHAR(64) NOT NULL,
task_id VARCHAR(64) NOT NULL,
activity_id VARCHAR(64) NOT NULL,
operator_id VARCHAR(64) NOT NULL,
decision VARCHAR(16) NOT NULL, -- APPROVE / REJECT / TRANSFER
comment VARCHAR(1000) NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
KEY idx_process_time (process_id, created_at)
);
附件要走对象存储(S3 / OSS),数据库里只存 key 与元数据。要注意三件事:附件与任务绑定(哪个节点上传的)、附件的访问权限(不能通过猜测 URL 访问)、附件的保留期(审批流附件通常要求长期保留)。大附件还要限制单文件大小、做病毒扫描、对图片压缩,否则审批页面加载缓慢且移动端上传频繁失败。
11. SLA、催办与超时升级
人工任务必须有超时机制,否则任务会无限期挂着。三件事要做:
- 截止时间(dueDate):任务创建时设定,用于排序与提醒。
- 催办:接近截止时间时通知处理人(比如提前 4 小时、提前 1 小时)。
- 超时升级:超过截止时间后自动转给上级或标记为超时。
<bpmn:boundaryEvent id="approveTimeout" attachedToRef="financeApprove"
cancelActivity="true">
<bpmn:timerEventDefinition>
<bpmn:timeDuration xsi:type="bpmn:tFormalExpression">P2D</bpmn:timeDuration>
</bpmn:timerEventDefinition>
</bpmn:boundaryEvent>
// 用作业执行器做定时催办,避免依赖外部调度
@Component
public class RemindJobHandler implements JavaDelegate {
@Override
public void execute(DelegateExecution execution) {
String taskId = (String) execution.getVariable("taskId");
notificationService.remind(taskId, "审批即将超时,请及时处理");
}
}
「节假日顺延」这类需求要在计算 dueDate 时考虑工作日历,不要用简单的「加 N 天」。工作日历通常来自公司的人力系统,需要定期同步。
12. 审批历史与流程留痕
审批历史有两个数据来源:引擎的历史表(ACT_HI_*)与业务自己写的评论表。前者提供「流程走到哪、每个节点花了多久」,后者提供「审批人说了什么」。
面向用户的展示通常需要把两者合并成一条时间线:
SELECT h.ACT_ID_ AS node, h.START_TIME_, h.END_TIME_, h.ASSIGNEE_,
c.comment, c.decision
FROM ACT_HI_ACTINST h
LEFT JOIN approval_comment c
ON c.process_id = h.PROC_INST_ID_ AND c.activity_id = h.ACT_ID_
WHERE h.PROC_INST_ID_ = ?
ORDER BY h.START_TIME_;
留痕的合规要求:审批记录不可篡改(至少应用层不提供修改接口)、保留期明确(金融场景常见 5 年以上)、可导出(审计时能给出完整材料)。这些要求决定了审批数据不能只存在引擎的历史表里(它会被 TTL 清理),业务侧必须有独立的持久化。
13. 组织架构同步
审批流的候选组依赖组织架构数据。同步方式有三种:
- 全量同步:定期(比如每天)把组织架构全量拉进引擎的身份表。简单,但有延迟。
- 增量同步:监听组织架构变更事件,实时更新。复杂,但一致性好。
- 实时查询:不落库,每次解析时调用组织架构接口。最新,但有性能与稳定性风险。
// 全量同步示例(定时任务)
@Scheduled(cron = "0 0 3 * * ?")
public void syncOrg() {
List<OrgUser> users = orgClient.listAllUsers();
for (OrgUser u : users) {
identityService.saveUser(toCamundaUser(u));
for (String group : u.getRoles()) {
identityService.createMembership(u.getId(), group);
}
}
}
推荐的组合是「全量同步兜底 + 实时查询覆盖关键节点」:日常用同步后的本地数据(快),对「审批人必须是最新的」这类关键节点用实时查询(准)。
14. 通知渠道设计
通知是审批流里最容易做过头的地方。三个原则:
- 按事件通知,不按时间轮询。通知的触发点是「任务创建」「任务转办」「即将超时」「流程结束」。
- 渠道可配置。站内信、邮件、企业微信、钉钉、短信,不同企业偏好不同,做成配置项而不是硬编码。
- 合并通知。一个人同时收到 20 条待办提醒会直接忽略全部,应该合并成一条摘要。
public interface Notifier {
void send(NotifyMessage msg);
}
@Component
public class CompositeNotifier implements Notifier {
private final List<Notifier> channels; // 按配置装配
@Override
public void send(NotifyMessage msg) {
channels.forEach(c -> {
try {
c.send(msg);
} catch (Exception e) {
log.warn("notify channel failed: {}", c.getClass().getSimpleName(), e);
}
});
}
}
通知失败不能影响流程推进,所以每个渠道的异常都要单独捕获。这一点很容易在实现时忘掉,导致「邮件服务挂了,审批流程也卡住」。
15. 批量审批与移动端
批量审批(一次处理多个任务)是提升效率的常见需求,但有两个陷阱:
- 批量操作必须逐个校验,不能假设「都通过」或「都失败」。中途失败要返回详细结果。
- 批量操作要有二次确认与操作上限(比如一次最多 50 条),避免误操作影响大量单据。
public BatchResult batchApprove(List<String> taskIds, String userId, String comment) {
BatchResult result = new BatchResult();
for (String taskId : taskIds) {
try {
approveOne(taskId, userId, comment);
result.success(taskId);
} catch (Exception e) {
result.failure(taskId, e.getMessage());
}
}
return result;
}
移动端要考虑三件事:页面在小屏上的信息密度(审批人只看关键字段)、弱网下的提交反馈、以及关键审批的生物识别二次确认。
16. 权限与数据可见性
审批流的权限有三个层次:
- 功能权限:能不能看到「待办」菜单、能不能点「审批」按钮。
- 数据权限:能看到哪些单据(本部门的、本人提交的、全部)。
- 字段权限:能看到单据的哪些字段(金额可能对某些角色隐藏)。
数据权限的实现方式是在查询上强制拼条件,而不是在应用层过滤:
public List<Task> myTasks(String userId, Set<String> roles) {
TaskQuery q = taskService.createTaskQuery();
if (!roles.contains("ADMIN")) {
q.taskCandidateGroupIn(groupsOf(userId)).or().taskAssignee(userId).endOr();
}
return q.list();
}
关键是「默认拒绝」:查询条件从「最小可见集」开始构造,而不是查全部再过滤。后者一旦漏掉一个过滤分支就会造成越权。
17. 落地路线图
- 第 1 周:跑通「提交 → 单人审批 → 完成」的最小链路,确认候选组解析与任务查询正确。
- 第 2 周:加入会签与驳回,验证多实例的完成条件与驳回后的重新提交。
- 第 3 周:加入超时、催办与转办委托,验证定时边界事件与通知渠道。
- 第 4 周:补齐审批历史、数据权限与移动端适配,做一次权限越权的渗透测试。
评估时重点看两个指标:审批人从收到通知到处理的平均时长(反映通知与体验设计),以及「被驳回后重新走完流程」的轮次分布(反映流程设计的合理性,轮次过多说明前置校验不足)。
18. 权衡取舍
| 选择 | 收益 | 代价 |
|---|---|---|
| 候选组而非直接指派 | 人员变动无需改流程 | 需要维护组织架构数据 |
| 引擎内置表单 | 与流程定义一体,部署简单 | 表达能力有限,样式受限 |
| 外部表单引擎 | 灵活、可版本化 | 多一个系统要维护 |
| 驳回回到发起人 | 简单,语义清晰 | 效率低,一轮驳回要重走全流程 |
| 驳回回到指定节点 | 效率高 | 实现复杂,需要维护节点历史 |
| 实时查询组织架构 | 数据最新 | 性能与可用性风险 |
| 定时全量同步 | 性能好 | 有延迟,人员变动不能立即生效 |
| 全量落库审批数据 | 可长期保留可导出 | 存储成本,需与引擎历史对账 |
19. 常见坑清单
- 用
camunda:assignee硬编码审批人,人员离职后流程卡死,应该用候选组加表达式。 - 会签不设 completionCondition,一个人不处理就永远卡住。
- 打开详情页就认领任务,产生大量僵尸认领,应该点击「处理」时才认领。
- 驳回到上一节点时没记录节点历史,只能回到发起人,用户需要重走全流程。
- 表单数据只存流程变量,流程结束后变量被历史清理,数据丢失。
- 表单不存版本号,表单定义变更后历史记录无法正确渲染。
- 通知渠道的异常没有单独捕获,邮件服务故障导致流程推进失败。
- 批量审批不逐个校验,一条失败导致整个批次回滚或部分状态不一致。
- 数据权限在应用层过滤而不是查询层拼条件,漏掉一个分支就造成越权。
- 组织架构只在启动时同步一次,新员工看不到待办。
- 审批附件用自增 ID 做 URL,可以被遍历下载,缺少权限校验。
20. 小结
人工任务的技术核心是「把人的不确定性建模进去」:用候选组代替硬编码指派人,用超时与升级代替无限等待,用轮次与节点历史代替简单的驳回,用版本化的表单代替一次性的表单定义。做到这四点,审批流的长期维护成本会显著降低。
工程上的优先级建议是:先把任务分配与查询做对(这是最高频的路径),再把驳回与会签做对(这是最容易被用户投诉的路径),最后补权限、通知与移动端。权限问题属于安全范畴,即使业务优先级不高也应该在第一个版本就做对,因为事后补权限的代价远高于一开始就设计。
如果审批只是流程中的一环,而流程本身还有大量自动编排,建议用 BPMN 引擎承载审批、把自动编排交给专用引擎,参考 工作流引擎全景与选型 里的混合架构讨论;如果审批规则本身很复杂(多级路由、评分、费率),则应该看 规则引擎与决策表 ,把规则从流程里抽出来独立维护。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。