Skip to content

Activiti任务生命周期、待办已办与并发审批

用户在审批中心看到的是“我的待办、待认领、已办理、转办、委派、通过、拒绝”,但引擎内部真正维护的是 Task、Execution、Identity Link、Variable 和 History。taskService.complete(taskId) 不是删除一行待办,而是在一个引擎命令中结束当前任务、推进 Execution、计算网关、创建下一任务、更新历史并提交事务。

生产审批接口也绝不能只调用 complete。它还必须处理服务端权限、业务状态、审批意见、幂等请求、并发完成、变量类型、历史查询和跨系统一致性。否则会出现越权审批、同一任务被处理两次、流程已走但业务状态没变、任务完成后已办查不到等问题。

学习目标

完成本页后,你应该能够:

  1. 区分 Task、Execution、ProcessInstance 和业务审批记录。
  2. 解释 User Task 从创建、候选、认领、分配、委派到完成的生命周期。
  3. 区分 assignee、candidate user、candidate group、owner 和 identity link。
  4. 正确查询个人待办、候选待办、已办和完整轨迹。
  5. 解释 claim 为什么需要并发控制,以及两个人同时认领/完成会怎样。
  6. 区分认领、取消认领、转办、委派、解决委派和完成任务。
  7. 正确设计全局变量、Task Local变量和历史变量。
  8. 写出带权限、幂等、审批记录和事务的完整 Java Demo。
  9. 处理大待办列表的分页、索引、冗余查询和数据权限。
  10. 排查任务查不到、重复审批、任务完成回滚、已办缺失和待办错人。

一、先区分四类数据

mermaid
flowchart TD
    A["ProcessInstance:一张业务单的一次流程运行"] --> B["Execution:引擎当前执行路径"]
    B --> C["Task:某个User Task产生的人工工作项"]
    D["业务审批记录:谁对哪张单做了什么"] --> A
    E["业务表:合同、报销单、资产申请"] --> A
对象主要回答完成Task后怎样
ProcessInstance这次流程是否仍运行、使用哪个定义版本可能继续运行,也可能结束
Execution当前流程Token在哪些节点/作用域推进、分裂、汇聚或删除
Runtime Task现在谁需要办理什么从运行时任务表移除
Historic Task过去谁何时办理过什么按历史级别和清理策略保留
业务审批记录审批意见、操作者、请求ID、业务结论由业务系统长期保留和审计

Task 不是业务单。一个合同流程可以先后产生主管、法务、财务多个 Task;并行时还可以同时存在多个 Task。

二、User Task从哪里产生

BPMN:

xml
<userTask id="legalReview"
          name="法务审核"
          activiti:candidateGroups="LEGAL_REVIEWER"/>

Execution 进入该节点时,引擎通常会:

mermaid
flowchart TD
    A["Execution进入User Task"] --> B["创建运行时Task实体"]
    B --> C["写taskDefinitionKey、名称、流程实例和Execution关系"]
    C --> D["解析assignee、candidate user/group"]
    D --> E["写Assignee或Identity Link"]
    E --> F["执行Task Create Listener"]
    F --> G["刷新运行时Task和历史记录"]
    G --> H["提交事务后待办可见"]

如果 Task Listener 抛异常,创建 Task 的整个引擎命令可能回滚,用户就查不到任务。不能只查 ACT_RU_TASK 后认定“引擎没创建”,还要查看触发该节点的请求/Job 异常。

三、Task常见字段怎样理解

字段含义常见误区
id当前Task实例唯一ID不是Activity ID,也不能跨实例复用
taskDefinitionKeyBPMN User Task节点ID不是Task表主键
name面向用户的任务名称名称可变,不适合做稳定逻辑判断
processInstanceId所属流程实例还需businessKey关联业务单
executionId所属执行路径并行时不同Task可能有不同executionId
assignee当前明确办理人null不等于任何人都能办理
owner委派等场景中的任务所有者不等于当前assignee
createTime任务创建时间不等于流程启动时间
dueDate业务到期时间元数据引擎不会天然替你实现所有SLA动作
priority优先级元数据不会自动实现复杂抢单调度
delegationState委派状态PENDING时通常需resolve而非直接当普通Task处理

四、assignee和candidate到底是什么

4.1 assignee

明确处理人。任务已分配给用户 u1001 后,其他候选人通常不应直接完成。

xml
<userTask id="managerReview"
          name="主管审批"
          activiti:assignee="${managerUserId}"/>

4.2 candidate user

候选用户可以看到并认领任务:

xml
<userTask id="expertReview"
          name="专家审核"
          activiti:candidateUsers="u1001,u1002"/>

硬编码用户只适合演示。生产人员变化应来自组织服务或业务变量。

4.3 candidate group

候选组表示某角色/组中的用户可认领:

xml
<userTask id="securityReview"
          name="安全审核"
          activiti:candidateGroups="SECURITY_REVIEWER"/>

Activiti 自带 Identity 数据与企业 IAM/组织系统未必是同一套。taskCandidateUser(userId) 是否能正确解析用户所属组,取决于 IdentityService 集成方式。很多商业系统会自己从组织服务获取 groupIds,再组合查询或维护任务投影表。

Task 与候选用户、候选组、参与者等关系通常通过 Identity Link 表达。它比 ASSIGNEE_ 单字段更丰富。

mermaid
flowchart TD
    A["Task"] --> B["Assignee:当前办理人"]
    A --> C["Candidate User:候选用户"]
    A --> D["Candidate Group:候选组"]
    A --> E["Owner:委派前所有者"]
    A --> F["Participant等Identity Link"]

五、任务生命周期

mermaid
flowchart TD
    A["User Task创建"] --> B{"是否直接指定assignee"}
    B -->|"是"| C["Assigned:个人待办"]
    B -->|"否"| D["Unclaimed:候选池任务"]
    D --> E["claim认领"]
    E --> C
    C --> F{"处理动作"}
    F -->|"取消认领"| D
    F -->|"转办"| G["更换assignee"]
    F -->|"委派"| H["Delegation PENDING"]
    H --> I["受托人resolve"]
    I --> C
    C --> J["complete完成"]
    G --> J
    J --> K["Runtime Task移除,Execution继续"]
    K --> L["Historic Task保留已办轨迹"]

不是每个任务都会经过所有状态。直接 assignee 的任务无需 claim;没有委派也不会出现 owner/delegationState。

六、查询我的待办不能只写一条模糊API

“我的待办”可能包含:

  1. 已经明确分配给我的任务。
  2. 我作为候选用户可以认领的任务。
  3. 我所属候选组可以认领的任务。
  4. 委派给我等待处理的任务。
  5. 代理关系下需要展示的任务。

产品要先定义列表范围,不能直接把某个 Query 方法名当业务规则。

6.1 查询已分配给我的任务

java
List<Task> assigned = taskService.createTaskQuery()
        .taskAssignee(userId)
        .active()
        .orderByTaskCreateTime()
        .desc()
        .listPage(offset, pageSize);

6.2 查询我可认领的候选任务

如果引擎 IdentityService 已正确接入用户组:

java
List<Task> candidate = taskService.createTaskQuery()
        .taskCandidateUser(userId)
        .active()
        .orderByTaskCreateTime()
        .desc()
        .listPage(offset, pageSize);

如果组关系来自外部 IAM,需要明确查询实现。有些版本支持传 groupIds 或 OR 查询能力,有些项目会维护独立待办索引。不要先把全量任务查进内存再用 Java 过滤。

6.3 候选或已分配组合查询

部分 Activiti 版本提供:

java
taskService.createTaskQuery()
        .taskCandidateOrAssigned(userId)
        .active()
        .listPage(offset, pageSize);

必须对当前版本做集成测试,确认候选组解析、委派状态、租户和分页总数符合业务定义。

6.4 为什么还要关联业务数据

Task 中通常没有合同金额、申请部门、数据等级等完整展示字段。简单列表可以批量取得 processInstanceId/businessKey 后查询业务表;复杂高并发审批中心可建设业务待办投影表或搜索索引。

禁止 N+1:

text
错误:查20个Task,再循环20次查流程实例、20次查业务表
正确:分页查Task → 批量查实例/businessKey → 批量查业务单 → 内存按ID组装

七、分页和总数为什么可能慢

java
TaskQuery query = taskService.createTaskQuery()
        .taskAssignee(userId)
        .active();

long total = query.count();

List<Task> page = query
        .orderByTaskCreateTime()
        .desc()
        .listPage(offset, pageSize);

风险:

  • 候选组查询需要关联 Identity Link 和组关系。
  • 加多个变量条件可能 Join 变量表并扩大结果集。
  • 深分页 offset 越大,数据库扫描和丢弃越多。
  • count 和列表是两条 SQL,中间可能有任务变化,结果不是严格快照。
  • 每次请求都查完整历史变量会增加 IO。

生产优化:

  • 查看当前 Activiti 版本真实 SQL 和执行计划。
  • 按租户、assignee/candidate、创建时间、流程 key 设计查询路径。
  • 页大小设上限,避免前端请求十万条。
  • 高数据量使用游标/锚点分页或审批投影表。
  • 对投影表使用 Outbox/CDC 和对账,不假设双写永不失败。
  • 不随意给引擎表加索引,先评估写放大、升级脚本和官方 schema。

八、claim认领全过程

java
taskService.claim(taskId, userId);

语义:将尚未分配的候选任务设置给某个 assignee。完整业务流程应是:

mermaid
flowchart TD
    A["用户请求认领taskId"] --> B["服务端读取Task"]
    B --> C["校验Task仍活动且assignee为空"]
    C --> D["校验用户是候选人或属于候选组"]
    D --> E["调用claim"]
    E --> F["引擎更新assignee和Revision"]
    F --> G["事务提交"]
    G --> H["任务从候选池变为个人待办"]

8.1 两个人同时认领会怎样

两个请求都可能先读到 assignee=null,但数据库更新时必须只有一个成功。引擎通常通过 Revision/乐观锁和“已被认领”校验保证竞争结果。

mermaid
flowchart TD
    A["用户A和用户B同时认领"] --> B["两边都读取到候选Task"]
    B --> C["A先提交assignee=A"]
    C --> D["B更新时发现Task已认领或Revision变化"]
    D --> E["B得到冲突/已认领结果"]

业务接口应该把失败转换为明确的 TASK_ALREADY_CLAIMED,并返回最新 assignee 的脱敏信息,而不是统一返回 500。

8.2 claim不能替代权限校验

不能相信前端“只有候选人看得到按钮”。服务端要验证候选 Identity Link 和企业数据权限。不同 Activiti 版本对非候选用户直接 claim 的校验行为可能不同,业务安全不能依赖未验证的默认实现。

九、取消认领

常见做法:

java
taskService.setAssignee(taskId, null);

这会把个人任务放回候选池,但业务上应限制:

  • 当前操作者是 assignee 或管理员。
  • 任务尚未开始不可逆外部操作。
  • 原候选用户/组关系仍存在。
  • 记录取消认领原因和审计。

如果任务一开始就只有动态 assignee、没有候选关系,清空后可能成为无人可见任务。

十、转办和委派不是一回事

10.1 转办

转办通常表示把当前处理责任永久交给另一人:

java
taskService.setAssignee(taskId, targetUserId);

原办理人不再需要收回任务。业务要记录 from、to、原因和授权来源。

10.2 委派

委派表示原办理人临时交给受托人处理,受托人完成工作后任务通常返回 owner,由原办理人最终确认。

java
taskService.delegateTask(taskId, delegateUserId);

常见内部变化:

  • 原 assignee 保存为 owner。
  • 新 assignee 变成受托人。
  • delegationState 变为 PENDING

受托人不是直接完成整个 BPMN Task,而是解决委派:

java
taskService.resolveTask(taskId, variables);

任务返回 owner 后,owner 再调用 complete 推进流程。精确状态变化以当前 Activiti 版本测试为准。

10.3 对比

动作当前assigneeowner是否推进Execution
claim设置为认领人通常不变
unclaim清空通常不变
transfer/setAssignee换成目标人通常不自动保留原人
delegateTask受托人保存原处理责任人
resolveTask通常回到owner保留
completeTask结束不再是运行时Task

十一、complete内部到底发生什么

java
taskService.complete(taskId, variables);

不是一条 DELETE FROM ACT_RU_TASK。典型过程:

mermaid
flowchart TD
    A["根据taskId加载Task"] --> B["校验Task状态和Revision"]
    B --> C["合并本次流程变量"]
    C --> D["触发Task完成Listener"]
    D --> E["记录历史Task结束信息"]
    E --> F["移除运行时Task和Identity Link"]
    F --> G["唤醒关联Execution继续"]
    G --> H["经过Sequence Flow和Gateway"]
    H --> I["创建下一Task、Job或结束实例"]
    I --> J["刷新变量、Execution和History"]
    J --> K["提交数据库事务"]

任意阶段抛异常,整个命令通常回滚。因此“调用 complete 后仍能查到原 Task”不一定是引擎没执行,可能是后续网关或 Listener 失败导致事务正确回滚。

十二、两个人同时完成同一任务

即使前端按钮禁用,两个浏览器、重试网关或重复消息仍可能并发提交。

mermaid
flowchart TD
    A["请求A和请求B携带同一taskId"] --> B["两边查询时Task都存在"]
    B --> C["A先完成并提交"]
    C --> D["B完成时Task不存在或Revision冲突"]
    D --> E["B转换为幂等成功或明确已处理"]

业务选择:

  • 同一个 requestId 重试:返回第一次审批结果,视为幂等成功。
  • 不同用户竞争处理:后提交者返回“任务已由他人处理”。
  • 同一用户不同决定:不能静默成功,要返回已处理结果和原决定。

不能在捕获异常后再次无条件调用 complete

十三、变量作用域

13.1 流程/Execution变量

java
taskService.setVariable(taskId, "approved", Boolean.TRUE);

通常进入关联 Execution 的可继承变量作用域,后续网关可读取。

13.2 Task Local变量

java
taskService.setVariableLocal(taskId, "draftComment", "待补充材料");

局部变量属于当前 Task,任务完成后后续 Activity 未必能读取。适合临时 UI 状态的程度也要考虑历史配置和敏感数据,不应保存真正草稿附件。

13.3 完成时传变量

java
Map<String, Object> variables = new HashMap<>();
variables.put("approved", Boolean.TRUE);
variables.put("approvalLevel", "DIRECTOR");
taskService.complete(taskId, variables);

推荐在一次引擎命令中提交网关需要的变量,避免先 setVariable 成功、随后 complete 失败却留下半成品变量。若二者在同一 Spring 事务也可能一起回滚,但单次 API 更清晰。

13.4 类型契约

java
variables.put("approved", Boolean.TRUE);          // Boolean
variables.put("amount", new BigDecimal("99.90")); // BigDecimal
variables.put("result", "APPROVED");           // 稳定枚举字符串

不要把所有值都转 String,也不要放 JPA Entity、InputStream、密码、附件或大 JSON。Java 序列化对象会产生类版本和反序列化安全风险。

十四、审批意见应该存在哪里

Activiti 可提供 Task Comment/Attachment 等能力,但商业系统通常仍需要独立审批记录表,用于:

  • 稳定业务查询和报表。
  • 保存幂等 requestId、操作者身份快照、操作类型和来源。
  • 权限和脱敏控制。
  • 长期审计、归档和法规保留。
  • 不依赖引擎历史级别和清理策略。

示例:

sql
CREATE TABLE approval_record (
    id BIGINT PRIMARY KEY,
    request_id VARCHAR(64) NOT NULL,
    business_type VARCHAR(64) NOT NULL,
    business_id VARCHAR(64) NOT NULL,
    process_instance_id VARCHAR(128) NOT NULL,
    process_definition_id VARCHAR(128) NOT NULL,
    task_id VARCHAR(128) NOT NULL,
    task_definition_key VARCHAR(128) NOT NULL,
    operator_id VARCHAR(64) NOT NULL,
    action VARCHAR(32) NOT NULL,
    comment_text VARCHAR(1000),
    created_at TIMESTAMP NOT NULL,
    UNIQUE (request_id),
    UNIQUE (task_id, action)
);

UNIQUE(task_id, action) 只是示例。一个 Task 是否允许多次保存草稿、评论和最终动作,需要按业务模型设计。

十五、完整审批接口设计

Controller 只接收、校验和转换响应:

java
@RestController
@RequestMapping("/api/approval-tasks")
public class ApprovalTaskController {

    private final ApprovalTaskApplicationService applicationService;

    public ApprovalTaskController(
            ApprovalTaskApplicationService applicationService) {
        this.applicationService = applicationService;
    }

    @PostMapping("/{taskId}/decisions")
    public ApprovalResultResponse decide(
            @PathVariable String taskId,
            @Valid @RequestBody ApprovalDecisionRequest request,
            Authentication authentication) {

        ApprovalResult result = applicationService.decide(
                taskId,
                authentication.getName(),
                request.requestId(),
                request.decision(),
                request.comment());

        return ApprovalResultResponse.from(result);
    }
}

请求对象:

java
public record ApprovalDecisionRequest(
        @NotBlank String requestId,
        @NotNull ApprovalDecision decision,
        @Size(max = 1000) String comment) {
}

public enum ApprovalDecision {
    APPROVE,
    REJECT
}

如果项目使用 JDK 8,不能使用 record,应改为普通 DTO 类。文档明确版本差异,避免把语言语法错误归因于 Activiti。

十六、完整Service Demo

java
@Service
public class ApprovalTaskApplicationService {

    private final TaskService taskService;
    private final RuntimeService runtimeService;
    private final ApprovalRecordRepository approvalRecordRepository;
    private final BusinessDocumentRepository documentRepository;
    private final ApprovalPermissionService permissionService;

    public ApprovalTaskApplicationService(
            TaskService taskService,
            RuntimeService runtimeService,
            ApprovalRecordRepository approvalRecordRepository,
            BusinessDocumentRepository documentRepository,
            ApprovalPermissionService permissionService) {
        this.taskService = taskService;
        this.runtimeService = runtimeService;
        this.approvalRecordRepository = approvalRecordRepository;
        this.documentRepository = documentRepository;
        this.permissionService = permissionService;
    }

    @Transactional
    public ApprovalResult decide(
            String taskId,
            String operatorId,
            String requestId,
            ApprovalDecision decision,
            String comment) {

        ApprovalRecord existing = approvalRecordRepository
                .findByRequestId(requestId)
                .orElse(null);

        if (existing != null) {
            return ApprovalResult.from(existing);
        }

        Task task = taskService.createTaskQuery()
                .taskId(taskId)
                .active()
                .singleResult();

        if (task == null) {
            ApprovalRecord completed = approvalRecordRepository
                    .findFinalActionByTaskId(taskId)
                    .orElseThrow(() ->
                            new TaskAlreadyProcessedException(taskId));
            return ApprovalResult.from(completed);
        }

        if (task.getDelegationState() == DelegationState.PENDING) {
            throw new IllegalStateException("委派任务需要先解决委派,不能直接最终完成");
        }

        ProcessInstance instance = runtimeService
                .createProcessInstanceQuery()
                .processInstanceId(task.getProcessInstanceId())
                .singleResult();

        if (instance == null) {
            throw new IllegalStateException("任务存在但流程实例不存在");
        }

        String businessKey = instance.getBusinessKey();
        BusinessDocument document = documentRepository
                .findByIdForUpdate(businessKey)
                .orElseThrow(() -> new IllegalStateException("业务单据不存在"));

        permissionService.assertCanComplete(
                operatorId,
                task,
                document);

        boolean approved = decision == ApprovalDecision.APPROVE;

        Map<String, Object> variables = new HashMap<>();
        variables.put("approved", approved);
        variables.put("lastApproverId", operatorId);

        taskService.complete(taskId, variables);

        ApprovalRecord record = ApprovalRecord.finalDecision(
                requestId,
                document.getBusinessType(),
                document.getId(),
                instance.getProcessInstanceId(),
                instance.getProcessDefinitionId(),
                task.getId(),
                task.getTaskDefinitionKey(),
                operatorId,
                decision,
                sanitizeComment(comment));

        approvalRecordRepository.save(record);
        document.applyDecision(task.getTaskDefinitionKey(), decision);

        return ApprovalResult.from(record);
    }

    private String sanitizeComment(String comment) {
        // 这里只表示需要长度、字符和敏感信息策略;不能只依赖前端过滤
        return comment;
    }
}

16.1 为什么先查requestId

客户端超时重试时,相同 requestId 应返回第一次结果,避免重复 complete 和重复外部动作。

16.2 为什么Task不存在还要查审批记录

Task 完成后从运行时表移除。“查不到”可能是正常幂等重试,也可能是错误 taskId。审批记录可以区分两者。

16.3 为什么锁业务单

防止同一业务单的并行任务或其他业务接口同时修改状态。并行审批场景不能简单让每个 Task 都把业务状态改成最终 APPROVED,应由明确汇聚节点或状态聚合逻辑决定。

16.4 为什么先complete还是先保存记录需要讨论

在同一数据库和同一 Spring 事务中,二者通常一起提交/回滚。示例先 complete 后保存最终记录,若保存失败整个事务应回滚,Task 恢复。若数据库不同或事务未正确接入,则顺序无法提供原子性,需要 Outbox、补偿和对账。

十七、权限校验的完整层次

mermaid
flowchart TD
    A["用户已登录"] --> B["接口级权限:能否访问审批API"]
    B --> C["任务级权限:assignee/candidate/owner"]
    C --> D["数据权限:能否处理该业务单"]
    D --> E["状态权限:当前节点和业务状态是否允许动作"]
    E --> F["动作权限:通过、拒绝、转办、撤回是否允许"]
    F --> G["并发校验:Task是否仍活动"]

只校验 taskAssignee(userId) 不够:管理员代理、候选组、委派和业务部门权限都可能影响结果。也不能只查候选组,因为已被别人认领的任务不应再被候选人完成。

十八、已办为什么必须查历史

运行时 Task 完成后通常从 ACT_RU_TASK 移除,历史信息进入类似 ACT_HI_TASKINST 的表。因此:

text
待办 = Runtime Task
已办 = Historic Task + 业务审批记录

示例:

java
List<HistoricTaskInstance> handled = historyService
        .createHistoricTaskInstanceQuery()
        .taskAssignee(userId)
        .finished()
        .orderByHistoricTaskInstanceEndTime()
        .desc()
        .listPage(offset, pageSize);

注意:

  • 候选人没有认领/办理的任务不应算他的已办。
  • 委派中受托人 resolve 后,最终 assignee/owner 历史语义要按版本验证。
  • 自动跳过/删除的任务和正常完成任务要区分 deleteReason。
  • 历史级别过低可能缺少变量或 Activity 细节。
  • 历史清理后不能只依赖引擎表满足长期审计。

十九、怎样查完整审批轨迹

通常需要组合:

  • HistoricProcessInstance:流程起止、definitionId、businessKey。
  • HistoricActivityInstance:经过哪些 Activity。
  • HistoricTaskInstance:人工任务的 assignee、起止时间和删除原因。
  • HistoricVariableInstance/Detail:变量历史,受历史级别影响。
  • 业务审批记录:决定、意见、requestId、操作人快照。
mermaid
flowchart TD
    A["businessKey查询业务单"] --> B["processInstanceId"]
    B --> C["HistoricProcessInstance"]
    B --> D["HistoricActivityInstance时间线"]
    B --> E["HistoricTaskInstance人工轨迹"]
    B --> F["业务审批记录和意见"]
    C --> G["按开始时间和节点语义组装展示"]
    D --> G
    E --> G
    F --> G

并行 Activity 的时间区间会重叠,不能简单按开始时间推断严格先后。应展示分支和节点身份。

二十、Comment和AuthenticatedUser

部分 Activiti 版本使用 IdentityService 的 authenticated user 记录 Comment 操作者:

java
try {
    identityService.setAuthenticatedUserId(operatorId);
    taskService.addComment(taskId, processInstanceId, comment);
} finally {
    identityService.setAuthenticatedUserId(null);
}

为什么必须 finally 清理:相关实现可能使用 ThreadLocal。Web 线程池会复用线程,不清理可能让后续请求错误继承上一个用户。

即使使用引擎 Comment,仍建议保留业务审批记录,并对意见长度、XSS 展示、敏感词和数据权限做处理。

二十一、Task Listener生命周期边界

常见事件名称随版本略有差异,包括 create、assignment、complete、delete 等。

适合:

  • 创建任务后生成技术审计或 Outbox 事件。
  • 记录 assignee 变化。
  • 计算小型、稳定候选关系。

不适合:

  • 把完整审批业务隐藏在 Listener。
  • 同步发送不可幂等短信/邮件。
  • 在 complete Listener 再次调用 complete 当前 Task。
  • 吞异常让流程继续但业务状态未更新。

Listener 失败往往会让创建/完成 Task 的事务回滚。排查“任务突然消失/没生成”必须查 Listener 日志和最内层异常。

二十二、并行任务怎样更新业务状态

法务和财务并行时:

text
法务完成 ≠ 整个合同审批完成
财务完成 ≠ 整个合同审批完成
二者汇聚后的业务动作才可能标记联合审核完成

错误做法:每个 Task 完成都直接把合同改为 APPROVED

正确思路:

  • 每个任务写独立审批记录。
  • 业务状态保持 JOINT_REVIEWING
  • 汇聚后由明确 Service Task/业务聚合操作更新为下一状态。
  • 任一拒绝如何取消其他 Task 和回滚业务资源要提前建模。

二十三、跨数据库和消息一致性

若 Activiti 表和业务表在同一 DataSource、同一事务管理器中,complete 和业务更新可以参与本地事务。

如果跨数据库、调用外部系统或发 MQ:

mermaid
flowchart TD
    A["本地事务完成Task并更新业务表"] --> B["同事务写Outbox事件"]
    B --> C["提交成功"]
    C --> D["异步发布器发送消息"]
    D --> E["消费者按eventId幂等处理"]
    E --> F["对账和失败重试"]

不要在事务中先发消息再提交数据库,否则消息消费者可能看到尚未提交或最终回滚的状态。详细见:业务表与流程一致性

二十四、任务投影表什么时候需要

直接查询 Activiti Runtime 表适合规模不大、查询维度简单的系统。出现以下需求时可考虑独立投影:

  • 跨多流程、多业务类型统一搜索。
  • 按业务金额、部门、风险等级复杂排序筛选。
  • 数百万历史已办和复杂统计。
  • 外部组织权限和代理关系频繁变化。
  • 需要 Elasticsearch 全文检索。

投影表示例字段:

text
taskId, processInstanceId, businessKey, taskDefinitionKey,
assignee, candidateRole, tenantId, businessType,
title, applicantId, departmentId, priority,
createdAt, dueAt, status, sourceVersion

投影是派生数据,不是流程事实唯一来源。必须设计事件重放、幂等 upsert、延迟监控和周期对账。

二十五、故障一:用户查不到待办

mermaid
flowchart TD
    A["确认userId、tenantId和查询条件"] --> B["按taskId/processInstanceId直接查Task"]
    B --> C{"Task是否存在"}
    C -->|"否"| D["查Execution、Job、Listener异常和历史"]
    C -->|"是"| E["检查assignee、candidate Identity Link"]
    E --> F["检查用户组解析和组织缓存"]
    F --> G["检查是否已被别人claim或delegate"]
    G --> H["检查业务数据权限和投影延迟"]

常见原因:

  • Task 尚未创建,上一个异步 Job 失败。
  • Task 已完成,只能从历史查询。
  • 已被其他人认领。
  • candidate group 存在,但外部 IAM 组关系未接入引擎查询。
  • tenantId/流程 key/时间条件错误。
  • 待办投影消费延迟或丢事件。
  • Listener 创建 Task 时异常导致事务回滚。

二十六、故障二:无权限用户完成了任务

检查:

  • 接口是否只按 taskId 调 complete
  • 是否信任前端传 operatorId。
  • claim/complete 前是否校验 assignee/candidate和数据权限。
  • 管理员代理是否有审计和范围限制。
  • Task 创建时动态 assignee 是否被恶意变量影响。
  • 发布模型是否允许任意表达式调用权限 Bean。

修复不是只隐藏按钮,而是给 Service 增加服务器端授权和回归测试,并审计历史受影响任务。

二十七、故障三:任务完成接口报错,刷新后仍在

先看最内层异常:

  • 网关变量不存在/类型错误。
  • 下一 Task Listener 创建失败。
  • Service Task Delegate 超时或抛异常。
  • 乐观锁冲突。
  • 业务审批记录唯一约束冲突。
  • 业务表更新失败。

如果都在同一事务,原 Task 仍在通常是正确回滚。不要手工删 Task,要修复根因后用同一幂等请求安全重试。

二十八、故障四:同一任务出现两条审批记录

检查:

  • 是否有 requestId 唯一约束。
  • 两个请求是否使用不同 requestId 并同时通过前置查询。
  • 审批记录是否在 complete 失败后单独提交。
  • 消费者是否至少一次投递但没有幂等。
  • Listener 和业务 Service 是否各写了一次记录。

数据库唯一约束是并发下最后防线,不能只依赖“先查询不存在再插入”。

二十九、故障五:已办查不到或办理人不对

检查:

  • 是否误查 Runtime Task。
  • History Level 是否记录 Task/Identity/变量。
  • 任务是否 claim 后才完成,assignee 是否真正设置。
  • 委派场景展示受托人、owner还是最终完成人的业务定义。
  • 历史是否已清理/归档。
  • 业务审批记录是否因跨事务失败缺失。
  • 查询是否错误限制 definitionVersion 或 tenantId。

三十、故障六:待办列表越来越慢

证据顺序:

  1. 捕获当前 Activiti 版本生成的真实 SQL。
  2. 使用数据库 EXPLAIN 查看扫描行数、Join 顺序和排序临时表。
  3. 统计 Task、Identity Link、Variable、History 表规模和数据倾斜。
  4. 检查候选组数量、IN 列表、变量过滤和深分页。
  5. 检查 N+1 业务查询和序列化时间。
  6. 决定索引、查询改写、归档或投影,而不是先盲目加缓存。

缓存待办有强一致性风险:Task 已被完成但缓存仍展示时,完成接口仍必须以引擎当前状态为准。

三十一、生产监控

建议监控:

指标维度/说明
活跃Task数量processKey、definitionVersion、taskDefinitionKey、tenant
Task创建/完成速率发现流程停滞
Task停留时长P50/P95/P99发现SLA风险
claim冲突数发现候选池竞争或重复点击
complete失败分类权限、已处理、乐观锁、表达式、Delegate、数据库
最老Task年龄发现僵尸待办
委派PENDING数量和年龄发现委派未解决
待办投影延迟/对账差异发现派生数据不一致

不要用 taskId、processInstanceId、businessKey、userId 作为指标 Label。高基数身份放结构化日志和 Trace。

三十二、日志应记录什么

推荐字段:

text
requestId
operatorId(按安全策略脱敏)
taskId
taskDefinitionKey
processInstanceId
processDefinitionId
businessType
businessId(必要时脱敏)
action
result
errorCode
durationMs

不要记录密码、Token、完整身份证、病历详情、完整流程变量和未脱敏审批意见。

三十三、常见误区

误区准确结论后果
Task就是业务单一张业务单可产生多个Task状态和查询模型混乱
assignee为空谁都能办可能是候选池任务,需要Identity Link和权限越权审批
claim只是一条普通更新存在多人并发竞争和Revision控制把正常冲突当500
转办等于委派委派有owner和resolve语义任务永久交错或无法完成
complete只是删除任务它推进Execution并可能创建网关/Task/Job不理解后续异常为何回滚
Task查不到就是错误完成后Runtime Task正常消失重试被误判为系统故障
已办继续查ACT_RU_TASK已办来自History和业务记录流程结束后记录消失
前端隐藏按钮就是权限必须服务端校验任务和数据权限可伪造请求越权
先查再插就能防重复并发下两个请求可同时查不到重复审批记录
流程变量都用String网关类型转换不可靠分支走错/异常
给任务表随便加索引会影响写入和升级新慢SQL或升级失败

三十四、面试标准回答

Activiti任务生命周期是什么

Execution进入User Task后创建运行时Task和候选Identity Link。任务可以直接分配给assignee,也可以进入候选池后被claim;转办会更换assignee,委派会保存owner并进入PENDING,受托人resolve后通常返回owner。complete会结束运行时Task、推进Execution、计算网关并创建下一任务或结束实例,已办从历史Task和业务审批记录查询。

assignee和candidate有什么区别

assignee是当前明确办理人;candidate user/group只是可认领候选关系。候选任务被某人claim后通常变成他的个人待办,其他候选人不应再完成。候选关系也不能替代业务数据权限,服务端必须校验登录用户、任务关系、业务范围和当前状态。

两个人同时认领或完成任务会怎样

两个请求可能都先读到相同Task,但引擎通过任务状态、Revision和乐观锁使最终只有一个状态变更成功。业务层要把冲突区分为同requestId幂等重试和不同用户竞争;数据库审批记录还要有requestId/task动作唯一约束,不能只靠前置查询。

转办和委派有什么区别

转办通常永久把assignee改为另一个人,原处理人不再收回;委派是临时让受托人处理,引擎保存owner并将delegationState设为PENDING,受托人resolve后任务通常回到owner,再由owner最终complete。具体状态要以项目Activiti版本测试。

为什么complete报错后任务还在

complete不仅删除Task,还会写变量、执行Listener、推进Execution、计算网关、创建下一任务和写历史,这些通常属于同一数据库事务。后续表达式、Delegate、业务更新或乐观锁失败会让事务回滚,所以原Task仍在是正确结果。应保留异常证据、修复根因并用幂等请求重试,不能直接删表。

待办和已办怎样查询

待办查运行时Task,区分taskAssignee、candidate user/group和委派;已办查HistoricTaskInstance并结合业务审批记录。列表展示需要通过processInstanceId/businessKey批量关联业务表,避免N+1。高数据量复杂筛选可以建设派生投影,但必须有幂等、延迟监控和对账。

三十五、学习实验与验收

  1. 创建候选组Task,查询 Identity Link,使用两个用户并发 claim,记录一个成功一个冲突。
  2. 取消认领后验证任务回到候选池。
  3. 分别实现转办和委派,观察 assignee、owner、delegationState。
  4. 受托人调用 resolve,再由 owner complete,验证历史操作者语义。
  5. 两个线程同时 complete 同一taskId,验证乐观锁和业务幂等记录。
  6. 故意让后续网关表达式失败,证明原Task和业务更新回滚。
  7. 设置全局和Task Local变量,完成后验证后续节点可见性。
  8. 查询运行时待办和历史已办,说明数据为什么迁移。
  9. 分页查询100万级模拟数据,分析真实SQL和执行计划。
  10. 建设简化任务投影,模拟消息重复和丢失并通过对账修复。

验收时必须能回答:

  • Task、Execution、ProcessInstance和业务审批记录是什么关系?
  • candidate group怎样解析到企业用户?
  • claim并发为什么只有一个成功?
  • 转办、委派、resolve和complete分别改变什么?
  • complete为什么可能因下一节点错误而整体回滚?
  • 相同requestId重试和不同用户竞争怎样返回?
  • Task Local变量为什么不能可靠供后续网关读取?
  • Runtime待办和Historic已办为什么来自不同表?
  • 大待办列表为什么慢,从哪里取得真实SQL证据?
  • 投影表为什么必须对账而不能成为未经验证的事实源?

关联知识点