人工任务与审批流表单

本文拆解人工任务与审批流的完整工程实现,回答审批人怎么解析、会签与或签怎么落地、驳回后如何重新提交、表单如何与流程绑定。覆盖候选人模型、认领与完成、加签转办委托、驳回撤回与重提、表单定义与数据存储、SLA 催办与超时升级、组织架构同步、审批留痕与移动端适配,并给出 BPMN 与 Java 的真实代码。

引言

审批流是工作流引擎最经典的应用场景,也是最容易被低估的场景。它的技术难点不在流程流转,而在「人」带来的不确定性:审批人可能离职、可能休假、可能同时被指派到十个任务、可能对同一个单据反复驳回重提。这些情况在测试环境里不会出现,上线后却天天发生。

更麻烦的是审批流的规则往往由业务方定义且频繁变化:「金额超过 10 万要加一级总监审批」「跨部门的申请要先部门负责人会签」「节假日顺延到工作日」——这些规则如果没有被引擎建模,就会变成散落在代码里的 if-else。

本文按「任务生命周期」的顺序展开:任务如何被创建与分配(候选人模型)、如何被认领与完成、会签与加签如何实现、驳回与撤回如何处理、表单如何与流程绑定、超时与催办如何设计、审批历史如何留痕。每一部分都给出 BPMN 与 Java 的可落地写法。想先看 BPMN 基础的读者,可以从 BPMN 2.0 与 Camunda 实战 开始。

目录

  1. 人工任务的技术难点
  2. 任务的分配模型
  3. 候选人与候选组的解析
  4. 认领、完成与释放
  5. 会签与或签的实现
  6. 加签、转办与委托
  7. 驳回、撤回与重新提交
  8. 表单的定义与流程绑定
  9. 表单数据的存储与校验
  10. 审批意见与附件
  11. SLA、催办与超时升级
  12. 审批历史与流程留痕
  13. 组织架构同步
  14. 通知渠道设计
  15. 批量审批与移动端
  16. 权限与数据可见性
  17. 落地路线图
  18. 权衡取舍
  19. 常见坑清单
  20. 小结

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. 常见坑清单

  1. 用 camunda:assignee 硬编码审批人,人员离职后流程卡死,应该用候选组加表达式。
  2. 会签不设 completionCondition,一个人不处理就永远卡住。
  3. 打开详情页就认领任务,产生大量僵尸认领,应该点击「处理」时才认领。
  4. 驳回到上一节点时没记录节点历史,只能回到发起人,用户需要重走全流程。
  5. 表单数据只存流程变量,流程结束后变量被历史清理,数据丢失。
  6. 表单不存版本号,表单定义变更后历史记录无法正确渲染。
  7. 通知渠道的异常没有单独捕获,邮件服务故障导致流程推进失败。
  8. 批量审批不逐个校验,一条失败导致整个批次回滚或部分状态不一致。
  9. 数据权限在应用层过滤而不是查询层拼条件,漏掉一个分支就造成越权。
  10. 组织架构只在启动时同步一次,新员工看不到待办。
  11. 审批附件用自增 ID 做 URL,可以被遍历下载,缺少权限校验。

20. 小结

人工任务的技术核心是「把人的不确定性建模进去」:用候选组代替硬编码指派人,用超时与升级代替无限等待,用轮次与节点历史代替简单的驳回,用版本化的表单代替一次性的表单定义。做到这四点,审批流的长期维护成本会显著降低。

工程上的优先级建议是:先把任务分配与查询做对(这是最高频的路径),再把驳回与会签做对(这是最容易被用户投诉的路径),最后补权限、通知与移动端。权限问题属于安全范畴,即使业务优先级不高也应该在第一个版本就做对,因为事后补权限的代价远高于一开始就设计。

如果审批只是流程中的一环,而流程本身还有大量自动编排,建议用 BPMN 引擎承载审批、把自动编排交给专用引擎,参考 工作流引擎全景与选型 里的混合架构讨论;如果审批规则本身很复杂(多级路由、评分、费率),则应该看 规则引擎与决策表 ,把规则从流程里抽出来独立维护。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「工作流引擎」更多文章

  1. 工作流成本优化
  2. 执行器与资源隔离
  3. 调度、回填与补数