淘车湾项目实战七_流程审核信息分析及实现

O泡李华 8

淘车湾项目实战(七):流程审核信息分析及实现

本章位置:第三阶段 Java 企业项目 + AI 助手
对应课程:淘车湾项目实战——流程审核信息分析及实现
前置知识:淘车湾项目实战(一)~(六)、Activiti7/BPMN、Spring Security、若依 RBAC、DataScope、Vue3、Element Plus
后续衔接:我的待办/已办完善、流程图高亮、项目总结与 Bug 调试
学习目标:完成流程审批运行阶段的核心业务,包括我的待办、候选任务、已领取任务、claim/unclaim、审批详情、审批通过/拒绝、审批意见、历史任务、业务审批记录、审批时间线,以及若依用户角色与 Activiti 任务权限的整合。


一、本章到底解决什么问题

上一章已经完成:

BPMN
↓
Deployment
↓
ProcessDefinition
↓
Settlement Submit
↓
ProcessInstance
↓
UserTask

也就是说:

流程已经跑起来了

但用户现在还没有真正好用的审批页面。

本章要解决:

当前用户有哪些待办?

哪些任务属于我的角色?

任务被谁领取?

我能不能领取?

我能不能审批?

审批详情显示什么?

通过以后去哪?

拒绝以后去哪?

审批意见存哪里?

已经审批过的记录去哪查?

审批时间线怎么展示?

二、先把三类数据分清楚

本章最重要的第一步:

运行任务

历史任务

业务审批记录

这三类数据不是一回事。


三、运行任务

来源:

TaskService

主要表示:

当前还需要人处理的任务

例如:

经理审批
财务审批

只要任务完成:

就不再是当前 Runtime Task

四、历史任务

来源:

HistoryService

表示:

曾经出现过的任务

包括:

已完成
已结束
曾经由谁处理
开始时间
结束时间

五、业务审批记录

来源:

car_approval_record

它保存:

审批动作
审批人
审批意见
业务 ID
节点名称
时间

六、为什么三者都需要

TaskService
解决“现在谁要做什么”

HistoryService
解决“流程过去发生过什么”

car_approval_record
解决“业务页面怎么友好展示审批记录”

七、一个很重要的误区

任务完成后:

taskService
    .createTaskQuery()
    .taskId(taskId)
    .singleResult();

返回:

null

很多初学者会以为:

数据丢了

其实不是。

因为:

TaskService 查的是运行任务

已经完成:

应该去 HistoryService 查

八、待办任务是什么

“我的待办”一般包含:

我已经领取的任务

+
我有资格领取的候选任务

九、已领取任务

特点:

task.assignee = 当前用户

十、候选任务

特点:

assignee 为空

但当前用户:

属于 candidateUser
或
candidateGroup

十一、本项目主要使用 CandidateGroup

因为若依已经有:

sys_role.role_key

例如:

store_manager

finance

BPMN:

candidateGroups="store_manager"

或:

candidateGroups="finance"

十二、当前登录用户角色来源

若依:

LoginUser loginUser =
        SecurityUtils.getLoginUser();

然后:

SysUser
↓
roles
↓
roleKey

十三、提取 roleKey

示意:

private List<String>
    getCurrentRoleKeys() {

    LoginUser loginUser =
            SecurityUtils
                .getLoginUser();

    if (
        loginUser == null
        ||
        loginUser.getUser() == null
    ) {
        return Collections.emptyList();
    }

    return loginUser
        .getUser()
        .getRoles()
        .stream()
        .map(
            SysRole::getRoleKey
        )
        .filter(
            StringUtils::isNotBlank
        )
        .distinct()
        .toList();
}

十四、为什么 roleKey 要 distinct

一个用户:

可能因为数据重复
或者多个角色映射

出现重复值。

虽然正常数据库不该重复:

仍可以简单去重

十五、我的待办查询思路

当前用户 ID
↓
当前 roleKeys
↓
查询 assignee = 当前用户
↓
查询 candidateGroup in roleKeys
↓
合并
↓
去重
↓
转换 WorkflowTaskVO

十六、为什么不能只查 candidateGroup

因为任务被 claim 后:

assignee = 当前用户

有些查询逻辑里:

它已经不再属于普通候选任务结果

所以:

还要查 assignee

十七、为什么不能只查 assignee

候选任务还没人领取:

assignee = null

如果只查 assignee:

用户永远看不到待领取任务

十八、WorkflowTaskVO

public class WorkflowTaskVO {

    private String taskId;

    private String taskName;

    private String taskDefinitionKey;

    private String processInstanceId;

    private String processDefinitionId;

    private Long businessId;

    private String businessNo;

    private String businessType;

    private String assignee;

    private Boolean claimed;

    private LocalDateTime createTime;
}

十九、为什么要 businessType

未来不仅:

SETTLEMENT

还可能:

REFUND

DISCOUNT

PURCHASE

所以待办可以:

统一任务中心

当前课程:

先处理 SETTLEMENT

二十、businessId 从哪里来

不要让前端传。

通过:

Task
↓
processInstanceId
↓
ProcessInstance / HistoricProcessInstance
↓
businessKey
↓
settlementId

二十一、为什么查待办时 Runtime ProcessInstance 还存在

因为:

任务还没完成

流程:

仍在运行

所以可以:

runtimeService
    .createProcessInstanceQuery()

二十二、把 Task 转业务 VO

伪代码:

private WorkflowTaskVO
    toTaskVO(
        Task task
    ) {

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

    if (
        instance == null
    ) {
        throw new ServiceException(
            "流程实例不存在"
        );
    }

    Long settlementId =
            Long.valueOf(
                instance.getBusinessKey()
            );

    CarSettlement settlement =
            settlementMapper
                .selectById(
                    settlementId
                );

    WorkflowTaskVO vo =
            new WorkflowTaskVO();

    vo.setTaskId(
        task.getId()
    );

    vo.setTaskName(
        task.getName()
    );

    vo.setTaskDefinitionKey(
        task.getTaskDefinitionKey()
    );

    vo.setProcessInstanceId(
        task.getProcessInstanceId()
    );

    vo.setProcessDefinitionId(
        task.getProcessDefinitionId()
    );

    vo.setBusinessId(
        settlementId
    );

    vo.setBusinessNo(
        settlement
            .getSettlementNo()
    );

    vo.setBusinessType(
        "SETTLEMENT"
    );

    vo.setAssignee(
        task.getAssignee()
    );

    vo.setClaimed(
        StringUtils.isNotBlank(
            task.getAssignee()
        )
    );

    return vo;
}

二十三、这里为什么不能无脑 selectById

因为:

还要考虑 DataScope

当前用户的任务:

理论上应该对应其可访问业务

但为了安全:

仍要验证业务归属

二十四、待办列表也要 DataScope 吗

推荐:

需要

至少:

最终业务 VO 组装前
验证 settlement 属于当前用户数据范围

二十五、为什么 Task 权限和 DataScope 都要有

Task 表示:

流程有资格处理

DataScope 表示:

业务数据有资格访问

两者:

共同成立

才安全。


二十六、举例

用户:

roleKey = finance

理论上能处理财务任务。

但如果是:

沈阳店财务

不应该:

审批大连店结算

所以还要:

门店 DataScope

二十七、功能权限

Controller:

car:workflow:task:list

决定:

能不能进入审批任务中心

二十八、候选组

决定:

当前任务是否属于用户角色

二十九、DataScope

决定:

这张结算是不是当前用户可处理的数据

三十、完整三层权限

@PreAuthorize
↓
Activiti Task Identity
↓
Business DataScope

三十一、我的待办 Controller

@RestController
@RequestMapping(
    "/car/workflow/task"
)
public class WorkflowTaskController
        extends BaseController {

    private final WorkflowTaskService
            workflowTaskService;

    public WorkflowTaskController(
            WorkflowTaskService workflowTaskService
    ) {
        this.workflowTaskService =
                workflowTaskService;
    }
}

三十二、待办接口

@PreAuthorize(
    "@ss.hasPermi('car:workflow:task:list')"
)
@GetMapping("/todo")
public TableDataInfo todo(
        WorkflowTaskQueryDTO query
) {

    List<WorkflowTaskVO> list =
            workflowTaskService
                .selectTodoTasks(
                    query
                );

    return getDataTable(
        list
    );
}

三十三、Activiti TaskQuery 和若依 PageHelper 的问题

要注意:

TaskService 查询
不是普通 MyBatis SQL

所以不能机械:

startPage()

然后:

taskService.list()

期望 PageHelper 接管。


三十四、正确分页思路

Activiti 查询通常有:

listPage(firstResult, maxResults)

三十五、例如

int first =
        (pageNum - 1)
        * pageSize;

List<Task> tasks =
        query.listPage(
            first,
            pageSize
        );

long total =
        query.count();

三十六、为什么这章分页要单独处理

因为数据来源:

不是 Mapper

而是:

流程引擎 API

三十七、候选 + assignee 两份结果怎么分页

这是一个实际难点。

简单课程方案:

分别查
↓
合并
↓
去重
↓
内存分页

三十八、这种方案适合什么情况

课程项目:

任务数量很少

完全够用。


三十九、生产大量任务时

需要:

更严谨统一查询

或:

任务索引表
搜索引擎
专用查询层

当前:

不展开

四十、课程版待办查询

public List<WorkflowTaskVO>
    selectTodoTasks(
        WorkflowTaskQueryDTO query
    ) {

    String userId =
            String.valueOf(
                SecurityUtils
                    .getUserId()
            );

    List<String> roleKeys =
            getCurrentRoleKeys();

    List<Task> assignedTasks =
            taskService
                .createTaskQuery()
                .taskAssignee(
                    userId
                )
                .orderByTaskCreateTime()
                .desc()
                .list();

    List<Task> candidateTasks =
            new ArrayList<>();

    for (
        String roleKey
        :
        roleKeys
    ) {

        List<Task> roleTasks =
                taskService
                    .createTaskQuery()
                    .taskCandidateGroup(
                        roleKey
                    )
                    .orderByTaskCreateTime()
                    .desc()
                    .list();

        candidateTasks.addAll(
            roleTasks
        );
    }

    Map<String, Task> taskMap =
            new LinkedHashMap<>();

    assignedTasks.forEach(
        task ->
            taskMap.put(
                task.getId(),
                task
            )
    );

    candidateTasks.forEach(
        task ->
            taskMap.putIfAbsent(
                task.getId(),
                task
            )
    );

    return taskMap
        .values()
        .stream()
        .map(
            this::toTaskVO
        )
        .toList();
}

四十一、为什么用 LinkedHashMap

同时实现:

去重
+
尽量保留顺序

四十二、这里还有一个问题

多个角色:

finance
manager
admin

可能同一个任务:

匹配多个组

所以:

必须按 taskId 去重

四十三、能不能使用 taskCandidateGroupIn

某些经典 TaskQuery API 版本:

提供 group in 类能力

但不同 Activiti 版本具体 API:

要以当前依赖为准

课程代码:

用循环查询更容易兼容和理解

四十四、我的待办查询条件

可以支持:

业务编号

任务名称

流程名称

创建时间

四十五、WorkflowTaskQueryDTO

public class WorkflowTaskQueryDTO {

    private String businessNo;

    private String taskName;

    private LocalDateTime beginTime;

    private LocalDateTime endTime;
}

四十六、业务编号过滤怎么做

TaskService 本身:

不知道 settlement_no

所以课程小数据:

组装业务 VO 后过滤

四十七、为什么不是把 settlementNo 放流程变量然后查

可以,

但:

不必为了查询复制过多业务数据到流程变量

四十八、复杂生产系统

可以:

额外建立 workflow_task_index

保存:

taskId
businessId
businessNo
deptId
assignee
status

当前课程:

不需要

四十九、claim 是什么

候选组任务:

很多人都有资格

某人决定:

“这单我来处理”

执行:

claim

五十、claim 后

task.assignee
=
currentUserId

五十一、为什么不能前端传 userId

错误:

{
  "taskId": "123",
  "userId": "999"
}

用户可能:

帮别人领取

五十二、正确

后端:

userId = SecurityUtils.getUserId()

五十三、claim 接口

POST /car/workflow/task/{taskId}/claim

五十四、Controller

@PreAuthorize(
    "@ss.hasPermi('car:workflow:task:claim')"
)
@Log(
    title = "流程任务",
    businessType =
        BusinessType.UPDATE
)
@PostMapping(
    "/{taskId}/claim"
)
public AjaxResult claim(
        @PathVariable
        String taskId
) {

    workflowTaskService
        .claimTask(
            taskId
        );

    return success();
}

五十五、claim 之前校验什么

Task 存在

Task 当前未被领取

当前用户属于 CandidateGroup/CandidateUser

业务 DataScope 合法

五十六、为什么不能直接 taskService.claim

Activiti 自身的 claim:

会处理任务是否已被领取

但它不知道:

若依业务权限和 DataScope

所以:

业务 Service 先校验

五十七、CandidateGroup 权限校验

核心:

当前用户 roleKeys

是否与任务候选组:

有交集

五十八、怎么查某任务候选组

经典思路:

TaskService IdentityLink

或:

TaskQuery + candidate group

课程中可以采用:

针对当前 roleKeys
逐个验证 taskId + candidateGroup

五十九、示意

private boolean isCandidate(
        String taskId,
        List<String> roleKeys
) {

    for (
        String roleKey
        :
        roleKeys
    ) {

        long count =
                taskService
                    .createTaskQuery()
                    .taskId(
                        taskId
                    )
                    .taskCandidateGroup(
                        roleKey
                    )
                    .count();

        if (
            count > 0
        ) {
            return true;
        }
    }

    return false;
}

六十、claim Service

@Transactional(
    rollbackFor = Exception.class
)
public void claimTask(
        String taskId
) {

    Task task =
            getRunningTask(
                taskId
            );

    if (
        StringUtils.isNotBlank(
            task.getAssignee()
        )
    ) {
        throw new ServiceException(
            "该任务已被领取"
        );
    }

    List<String> roleKeys =
            getCurrentRoleKeys();

    if (
        !isCandidate(
            taskId,
            roleKeys
        )
    ) {
        throw new ServiceException(
            "当前用户不是该任务的候选审批人"
        );
    }

    Long settlementId =
            resolveSettlementId(
                task
            );

    requireAccessibleSettlement(
        settlementId
    );

    String userId =
            String.valueOf(
                SecurityUtils
                    .getUserId()
            );

    taskService.claim(
        taskId,
        userId
    );
}

六十一、claim 是否要写 car_approval_record

有两种做法。

第一版:

不需要

因为:

领取不是审批决定

六十二、如果业务要求审计

也可以记录:

CLAIM

当前课程:

审批记录主要保存 APPROVE / REJECT

六十三、unclaim 是什么

用户领取后:

发现不是自己处理

可以:

取消领取

任务重新回到:

候选组

六十四、unclaim 条件

必须:

任务存在

assignee = 当前用户

六十五、不能帮别人 unclaim

如果:

assignee = user100

当前:

user200

不允许:

取消别人的领取

六十六、unclaim 接口

POST /car/workflow/task/{taskId}/unclaim

六十七、Service

@Transactional(
    rollbackFor = Exception.class
)
public void unclaimTask(
        String taskId
) {

    Task task =
            getRunningTask(
                taskId
            );

    String currentUserId =
            String.valueOf(
                SecurityUtils
                    .getUserId()
            );

    if (
        !currentUserId.equals(
            task.getAssignee()
        )
    ) {
        throw new ServiceException(
            "只能取消自己领取的任务"
        );
    }

    taskService.unclaim(
        taskId
    );
}

六十八、审批是否一定先 claim

课程推荐:

是

六十九、完整流程

候选任务
↓
Claim
↓
我的已领取任务
↓
打开审批详情
↓
Approve / Reject

七十、为什么这样更清楚

如果不 claim:

候选组 5 个人

都能:

同时点审批

需要更多并发冲突处理。

claim:

先明确处理人

七十一、审批详情接口

建议:

GET /car/workflow/task/{taskId}/detail

七十二、审批详情返回什么

任务信息

业务结算详情

审批历史

流程信息

七十三、WorkflowApprovalDetailVO

public class WorkflowApprovalDetailVO {

    private WorkflowTaskVO task;

    private SettlementDetailVO business;

    private List<ApprovalTimelineVO>
            timeline;
}

七十四、为什么审批详情直接复用 SettlementDetailVO

前面已经做了:

完整结算主表 + 明细

不要:

重新写第二套结算详情查询

七十五、审批详情安全链

taskId
↓
查运行 Task
↓
验证当前用户可处理
↓
从 ProcessInstance 获取 businessKey
↓
得到 settlementId
↓
DataScope 校验
↓
SettlementDetailVO
↓
审批 Timeline

七十六、打开详情时是否必须 task 已 claim

两种方案。

课程推荐:

候选任务可以看详情

但只有:

已被当前用户 claim

才能:

Approve/Reject

七十七、为什么

审批人通常要:

先看详情

再决定:

是否领取

七十八、前端按钮

候选任务:

查看
领取

七十九、已领取任务

查看
审批
取消领取

八十、被别人领取

当前用户:

不应该继续在待办候选列表看到

八十一、审批 DTO

public class ApprovalActionDTO {

    @NotNull(
        message = "审批结果不能为空"
    )
    private Boolean approved;

    @Size(
        max = 1000,
        message = "审批意见不能超过1000字"
    )
    private String comment;
}

八十二、拒绝必须有意见

if (
    Boolean.FALSE.equals(
        dto.getApproved()
    )
    &&
    StringUtils.isBlank(
        dto.getComment()
    )
) {
    throw new ServiceException(
        "审批拒绝时必须填写原因"
    );
}

八十三、通过是否必须写意见

第一版:

可选

八十四、为什么拒绝必须写

后续申请人:

必须知道为什么被拒

八十五、审批接口

POST /car/workflow/task/{taskId}/approve

八十六、Controller

@PreAuthorize(
    "@ss.hasPermi('car:workflow:task:approve')"
)
@Log(
    title = "结算流程审批",
    businessType =
        BusinessType.UPDATE
)
@RepeatSubmit(
    interval = 3000
)
@PostMapping(
    "/{taskId}/approve"
)
public AjaxResult approve(
        @PathVariable
        String taskId,

        @Valid
        @RequestBody
        ApprovalActionDTO dto
) {

    workflowTaskService
        .approveTask(
            taskId,
            dto
        );

    return success();
}

八十七、为什么审批也加 RepeatSubmit

防止:

用户连续双击通过

八十八、但 RepeatSubmit 不是最终保证

Task 完成后:

运行任务已经不存在

第二个请求:

查询 task = null

也应该:

友好提示“任务已处理”

八十九、审批第一步:查 Task

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

九十、task == null

可能:

taskId 不存在

task 已完成

流程已结束

统一:

审批任务不存在或已处理

九十一、审批必须检查 assignee

课程流程:

先 claim

所以审批时要求:

task.assignee == currentUserId

九十二、代码

String currentUserId =
        String.valueOf(
            SecurityUtils
                .getUserId()
        );

if (
    !currentUserId.equals(
        task.getAssignee()
    )
) {
    throw new ServiceException(
        "该任务未由当前用户领取"
    );
}

九十三、为什么不能只看角色

两个经理都有:

store_manager

A 已经领取。

B 虽然也是:

store_manager

但不能:

完成 A 已领取的任务

九十四、从 Task 找 Settlement

task.processInstanceId
↓
ProcessInstance
↓
businessKey
↓
settlementId

九十五、resolveSettlementId

private Long resolveSettlementId(
        Task task
) {

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

    if (
        instance == null
    ) {
        throw new ServiceException(
            "流程实例不存在或已结束"
        );
    }

    String businessKey =
            instance.getBusinessKey();

    if (
        StringUtils.isBlank(
            businessKey
        )
    ) {
        throw new ServiceException(
            "流程业务标识缺失"
        );
    }

    try {
        return Long.valueOf(
            businessKey
        );
    } catch (
        NumberFormatException e
    ) {
        throw new ServiceException(
            "流程业务标识格式错误"
        );
    }
}

九十六、为什么这里要捕获 NumberFormatException

如果流程部署或启动代码错误:

businessKey 不是 settlementId

不应该直接:

500 堆栈

而应该:

业务友好异常

九十七、审批前还要校验 Settlement 状态

至少:

approvalStatus = PROCESSING

九十八、为什么

如果业务因为其他异常:

已经作废

但任务还残留,

不能继续:

正常审批

九十九、这也是流程和业务一致性检查

Task 正常
≠
业务一定正常

一百、任务节点如何判断

task.getTaskDefinitionKey()

例如:

managerApprove

financeApprove

一百零一、后端根据节点决定流程变量

managerApprove
→ managerApproved

financeApprove
→ financeApproved

一百零二、resolveVariable

private String resolveApprovalVariable(
        String taskDefinitionKey
) {

    return switch (
        taskDefinitionKey
    ) {
        case "managerApprove"
            -> "managerApproved";

        case "financeApprove"
            -> "financeApproved";

        default
            -> throw new ServiceException(
                "当前流程节点不支持审批"
            );
    };
}

一百零三、为什么不能让前端传 taskDefinitionKey

因为:

流程节点必须以后端 Task 为准

一百零四、为什么不能让前端传 variableName

用户可能:

篡改流程条件

一百零五、添加 Activiti Comment

经典方式:

taskService.addComment(
    task.getId(),
    task.getProcessInstanceId(),
    dto.getComment()
);

一百零六、空意见怎么办

如果:

通过且没有意见

可以:

不添加 comment

一百零七、完成任务

Map<String, Object> variables =
        new HashMap<>();

variables.put(
    variableName,
    dto.getApproved()
);

taskService.complete(
    task.getId(),
    variables
);

一百零八、complete 后 Task 去哪里

Runtime:

消失

History:

保留

如果流程还有下一节点:

创建新的 Runtime Task

一百零九、经理通过

managerApprove
完成
↓
managerGateway
↓
financeApprove

一百一十、经理拒绝

managerApprove
完成
↓
managerGateway
↓
rejectEnd
↓
流程结束

一百一十一、财务通过

financeApprove
↓
approveEnd
↓
流程结束

一百一十二、审批业务记录

Task complete 前:

先准备记录对象

complete 后:

保存记录

都在:

同一事务

一百一十三、ApprovalRecord

public class CarApprovalRecord
        extends BaseEntity {

    private Long approvalRecordId;

    private String businessType;

    private Long businessId;

    private String processInstanceId;

    private String taskId;

    private String activityId;

    private String activityName;

    private Long operatorUserId;

    private String action;

    private String comment;
}

一百一十四、action

APPROVE

REJECT

一百一十五、为什么 action 不存 true/false

业务页面:

APPROVE
REJECT

更直观。


一百一十六、activityId

存:

managerApprove

financeApprove

一百一十七、activityName

存:

经理审批

财务审批

一百一十八、为什么 ID 和 Name 都存

ID:

稳定程序标识

Name:

历史展示快照

一百一十九、如果未来 BPMN 节点中文名改了

历史审批:

仍显示当时 activityName

一百二十、ApprovalRecord 插入

private void saveApprovalRecord(
        Long settlementId,
        Task task,
        ApprovalActionDTO dto
) {

    CarApprovalRecord record =
            new CarApprovalRecord();

    record.setBusinessType(
        "SETTLEMENT"
    );

    record.setBusinessId(
        settlementId
    );

    record.setProcessInstanceId(
        task.getProcessInstanceId()
    );

    record.setTaskId(
        task.getId()
    );

    record.setActivityId(
        task.getTaskDefinitionKey()
    );

    record.setActivityName(
        task.getName()
    );

    record.setOperatorUserId(
        SecurityUtils.getUserId()
    );

    record.setAction(
        Boolean.TRUE.equals(
            dto.getApproved()
        )
            ? "APPROVE"
            : "REJECT"
    );

    record.setComment(
        dto.getComment()
    );

    approvalRecordMapper
        .insertApprovalRecord(
            record
        );
}

一百二十一、为什么记录 operatorUserId 而不是用户名

用户名:

可能修改

userId:

稳定关联

一百二十二、页面如何显示审批人名字

查询审批记录时:

LEFT JOIN sys_user

取得:

nick_name

一百二十三、是否要同时保存 operatorName 快照

复杂审计系统:

可以

当前课程:

userId + JOIN

够用。


一百二十四、完成任务后更新业务状态

如果:

approved = false

当前 BPMN 拒绝直接结束。

业务:

approvalStatus = REJECTED

一百二十五、如果 approved = true

再查流程运行实例。


一百二十六、流程仍存在

说明:

还有后续节点

业务:

approvalStatus = PROCESSING

一百二十七、流程不存在

说明:

审批流已经结束

当前通过分支:

approvalStatus = APPROVED

一百二十八、完整 approveTask

@Transactional(
    rollbackFor = Exception.class
)
public void approveTask(
        String taskId,
        ApprovalActionDTO dto
) {

    Task task =
            getRunningTask(
                taskId
            );

    String currentUserId =
            String.valueOf(
                SecurityUtils
                    .getUserId()
            );

    if (
        !currentUserId.equals(
            task.getAssignee()
        )
    ) {
        throw new ServiceException(
            "该任务未由当前用户领取"
        );
    }

    Long settlementId =
            resolveSettlementId(
                task
            );

    CarSettlement settlement =
            requireAccessibleSettlement(
                settlementId
            );

    if (
        !ApprovalStatus
            .PROCESSING
            .getCode()
            .equals(
                settlement
                    .getApprovalStatus()
            )
    ) {
        throw new ServiceException(
            "当前结算单不处于审批中"
        );
    }

    if (
        Boolean.FALSE.equals(
            dto.getApproved()
        )
        &&
        StringUtils.isBlank(
            dto.getComment()
        )
    ) {
        throw new ServiceException(
            "审批拒绝时必须填写原因"
        );
    }

    String variableName =
            resolveApprovalVariable(
                task.getTaskDefinitionKey()
            );

    if (
        StringUtils.isNotBlank(
            dto.getComment()
        )
    ) {
        taskService.addComment(
            task.getId(),
            task.getProcessInstanceId(),
            dto.getComment()
        );
    }

    Map<String, Object> variables =
            Map.of(
                variableName,
                dto.getApproved()
            );

    taskService.complete(
        task.getId(),
        variables
    );

    saveApprovalRecord(
        settlementId,
        task,
        dto
    );

    if (
        Boolean.FALSE.equals(
            dto.getApproved()
        )
    ) {

        settlementMapper
            .updateApprovalStatus(
                settlementId,
                ApprovalStatus
                    .REJECTED
                    .getCode()
            );

        return;
    }

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

    if (
        running == null
    ) {

        settlementMapper
            .updateApprovalStatus(
                settlementId,
                ApprovalStatus
                    .APPROVED
                    .getCode()
            );

    } else {

        settlementMapper
            .updateApprovalStatus(
                settlementId,
                ApprovalStatus
                    .PROCESSING
                    .getCode()
            );
    }
}

一百二十九、审批后如果最终 APPROVED

结算:

可以进入支付阶段

一百三十、如果 REJECTED

课程第一版推荐:

不允许直接支付

一百三十一、拒绝后下一步怎么办

有两种业务设计。

方案 A:

审批拒绝
↓
保持 REJECTED
↓
业务人员查看原因
↓
管理员重新打开草稿

一百三十二、方案 B

流程拒绝:

自动退回 DRAFT

允许:

修改优惠
重新提交

一百三十三、课程当前推荐哪一个

为了后续:

流程审核信息

更清楚,

建议先:

approvalStatus = REJECTED

再提供:

reopen

业务动作。


一百三十四、为什么不自动 DRAFT

否则页面只看到:

草稿

很容易:

不知道它曾经被拒绝

一百三十五、reopen

接口:

POST /car/settlement/{id}/reopen

一百三十六、条件

approvalStatus = REJECTED

paymentStatus = UNPAID

流程已经结束

一百三十七、reopen 后

status = DRAFT

approvalStatus = PENDING / 根据规则重算
processInstanceId 可以保留历史值还是清空?

一百三十八、推荐

旧流程实例 ID:

不要简单覆盖历史意义

如果要重新提交新流程:

会产生新的 processInstanceId

一百三十九、一个字段只能保存最新流程怎么办

课程第一版:

process_instance_id 保存当前/最近一次

历史审批:

car_approval_record

和 Activiti History:

仍能找到旧流程

一百四十、更完整生产设计

可以建立:

car_business_process

记录:

businessType
businessId
processInstanceId
version
startTime
endTime
result

当前:

不增加

一百四十一、这说明流程重提会增加复杂度

所以本课程:

先完成一次审批闭环

一百四十二、我的已办是什么

表示:

当前用户曾经处理过的任务

主要通过:

HistoryService

查询。


一百四十三、HistoricTaskInstance

它包含:

taskId

taskName

assignee

processInstanceId

startTime

endTime

duration

具体字段:

按当前 Activiti 版本 API 为准

一百四十四、查询当前用户已办

经典思路:

historyService
    .createHistoricTaskInstanceQuery()
    .taskAssignee(
        currentUserId
    )
    .finished()
    .orderByHistoricTaskInstanceEndTime()
    .desc()
    .list();

不同版本排序 API 名称:

以实际依赖为准

一百四十五、为什么必须 finished

否则可能:

把当前尚未完成的历史任务记录

也混进:

已办

一百四十六、什么叫历史任务还没 finished

History 级别配置下:

运行任务可能同时有历史实例记录

所以:

已办应明确 finished

一百四十七、已办 VO

public class WorkflowDoneTaskVO {

    private String taskId;

    private String taskName;

    private String taskDefinitionKey;

    private String processInstanceId;

    private Long businessId;

    private String businessNo;

    private String action;

    private String comment;

    private String assigneeName;

    private LocalDateTime startTime;

    private LocalDateTime endTime;
}

一百四十八、action 从哪来

HistoricTaskInstance:

不一定直接知道

业务上的:

APPROVE / REJECT

所以可以查:

car_approval_record

根据:

taskId

补齐。


一百四十九、这就是为什么 ApprovalRecord 有价值

History:

知道任务完成

ApprovalRecord:

知道是通过还是拒绝

一百五十、comment

可以:

从 ApprovalRecord

直接取。


一百五十一、已办列表组合

HistoricTaskInstance
↓
taskId
↓
ApprovalRecord
↓
processInstanceId
↓
HistoricProcessInstance
↓
businessKey
↓
Settlement

一百五十二、为什么已办要用 HistoricProcessInstance

流程可能:

已经结束

这时:

RuntimeService 查不到

一百五十三、HistoricProcessInstance

通过:

historyService
    .createHistoricProcessInstanceQuery()
    .processInstanceId(
        processInstanceId
    )
    .singleResult();

获得:

businessKey

一百五十四、已办 businessId 解析

HistoricTask
↓
processInstanceId
↓
HistoricProcessInstance
↓
businessKey
↓
settlementId

一百五十五、运行和历史两个 resolve 方法

可以分别:

resolveRunningBusinessId

resolveHistoricBusinessId

一百五十六、不要强行一个方法全处理

因为:

Runtime ProcessInstance
和
HistoricProcessInstance

生命周期不同。


一百五十七、审批时间线是什么

例如:

2026-09-10 10:00
张三
提交结算

2026-09-10 10:05
李经理
经理审批:通过

2026-09-10 10:15
王财务
财务审批:通过

2026-09-10 10:15
流程结束:审批通过

一百五十八、时间线的数据来源

可以组合:

业务提交记录

car_approval_record

Activiti History

一百五十九、课程第一版

审批时间线主要:

car_approval_record

加:

结算创建/提交时间

一百六十、为什么不用纯 HistoryService 直接展示

HistoryService:

引擎视角

会包含:

Gateway

StartEvent

EndEvent

内部活动

业务用户不一定需要。


一百六十一、业务时间线应该更友好

只展示:

提交

经理审批

财务审批

最终结果

一百六十二、流程诊断页

才适合展示:

HistoricActivityInstance

完整节点。


一百六十三、ApprovalTimelineVO

public class ApprovalTimelineVO {

    private String nodeId;

    private String nodeName;

    private String operatorName;

    private String action;

    private String comment;

    private LocalDateTime time;
}

一百六十四、审批记录查询 SQL

SELECT
    r.activity_id,
    r.activity_name,
    r.action,
    r.comment,
    r.create_time,
    u.nick_name AS operator_name
FROM car_approval_record r
LEFT JOIN sys_user u
  ON u.user_id =
     r.operator_user_id
WHERE
    r.business_type =
        'SETTLEMENT'
    AND r.business_id =
        #{settlementId}
ORDER BY
    r.create_time ASC,
    r.approval_record_id ASC;

一百六十五、为什么 createTime + ID 排序

如果两条记录:

同一秒

仍可以:

稳定排序

一百六十六、时间线加入提交节点

Settlement:

createTime
submitTime

目前表里可能没有:

submit_time

推荐增加。


一百六十七、SQL

ALTER TABLE car_settlement
ADD submit_time DATETIME NULL;

一百六十八、为什么 submit_time 有用

create_time:

草稿创建时间

不等于:

正式提交审批时间

一百六十九、提交时

submit_time = NOW()

一百七十、审批最终时间

也可以增加:

approval_end_time

一百七十一、当前是否必须

不必须。

History:

也能查

课程第一版可以:

先只加 submitTime

一百七十二、Timeline 第一个节点

提交审批

操作人:

申请人

一百七十三、申请人怎么知道

Settlement 当前只有:

create_by

如果 create_by 是:

用户名字符串

也能显示。

更稳定:

可以增加 applicant_user_id

一百七十四、当前课程要不要改表

可以不改。

流程变量里前面已放:

applicantUserId

但历史页面:

业务表更方便

一百七十五、推荐轻量改进

car_settlement:

applicant_user_id BIGINT
submit_time DATETIME

一百七十六、为什么

以后:

我的申请

查询非常方便。


一百七十七、SQL

ALTER TABLE car_settlement
ADD applicant_user_id BIGINT NULL,
ADD submit_time DATETIME NULL;

一百七十八、提交时写

settlement.setApplicantUserId(
    SecurityUtils.getUserId()
);

settlement.setSubmitTime(
    LocalDateTime.now()
);

一百七十九、“我的申请”虽然不是本章主线

但后面:

很可能需要

这个字段:

非常实用

一百八十、我的已办 DataScope

已办任务是:

我自己处理过

是否还需要 DataScope?

推荐:

仍要

一百八十一、为什么

用户调部门或权限后:

历史业务访问范围

可能有新要求。

课程统一:

业务详情始终做 DataScope

一百八十二、已办列表是否允许看到业务编号

可以。

但点击详情:

再次后端校验

一百八十三、不要认为列表已经校验就够

HTTP 用户可以:

直接调 detail

一百八十四、历史审批详情

可以:

GET /car/workflow/history/{processInstanceId}

一百八十五、但 processInstanceId 能否直接信任

查询后:

必须解析 businessKey

再:

业务权限检查

一百八十六、流程历史 VO

public class ProcessHistoryVO {

    private String processInstanceId;

    private String processDefinitionId;

    private String businessKey;

    private LocalDateTime startTime;

    private LocalDateTime endTime;

    private List<HistoricActivityVO>
            activities;
}

一百八十七、HistoricActivityVO

public class HistoricActivityVO {

    private String activityId;

    private String activityName;

    private String activityType;

    private String taskId;

    private String assignee;

    private LocalDateTime startTime;

    private LocalDateTime endTime;
}

一百八十八、HistoricActivityInstance 用在哪里

后面流程图高亮:

非常重要

因为它可以告诉:

哪些 activityId 已经走过

一百八十九、本章先做什么

先:

查询流程历史节点

后面:

把 activityId 传给 bpmn-js

做高亮。


一百九十、历史活动查询

经典思路:

historyService
    .createHistoricActivityInstanceQuery()
    .processInstanceId(
        processInstanceId
    )
    .orderByHistoricActivityInstanceStartTime()
    .asc()
    .list();

具体方法:

以当前版本 API 为准

一百九十一、历史活动里会有 Gateway

例如:

managerGateway

一百九十二、业务审批时间线要不要显示 Gateway

不建议。


一百九十三、流程调试视图可以显示

后面流程图:

会高亮网关经过路径

一百九十四、审批页面前端结构

建议:

src/views/car/workflow/task/todo.vue
src/views/car/workflow/task/done.vue
src/views/car/workflow/task/components/ApprovalDrawer.vue
src/views/car/workflow/task/components/ApprovalTimeline.vue

一百九十五、为什么用 Drawer 很适合审批详情

列表:

不离开页面

点击:

右侧抽屉打开详情

方便:

连续处理任务

一百九十六、也可以独立详情页

如果业务复杂:

独立 route

课程第一版:

Drawer 更快实现

一百九十七、我的待办表格

建议列:

结算单号

任务名称

门店

客户

应收金额

优惠金额

任务状态

创建时间

操作

一百九十八、任务状态

前端可以:

待领取

处理中

不是 Activiti 业务 status 字段,

可以根据:

assignee 是否为空

推导。


一百九十九、已办表格

结算单号

任务名称

审批动作

审批意见

处理人

开始时间

完成时间

操作

二百、候选任务按钮

查看
领取

二百零一、我的任务按钮

查看
审批
取消领取

二百零二、ApprovalDrawer

上面:

任务信息

中间:

SettlementDetail

下面:

ApprovalTimeline

最底部:

审批意见
通过
拒绝

二百零三、如果只是查看候选任务

审批按钮:

不显示

显示:

领取

二百零四、如果已领取但不是当前用户

理论上:

不应该出现在当前人的待办

如果直接访问详情:

后端拒绝审批

二百零五、前端 task API

import request from '@/utils/request'

export function listTodoTasks(
    query
) {
    return request({
        url:
            '/car/workflow/task/todo',
        method:
            'get',
        params:
            query
    })
}

export function listDoneTasks(
    query
) {
    return request({
        url:
            '/car/workflow/task/done',
        method:
            'get',
        params:
            query
    })
}

二百零六、详情

export function getTaskDetail(
    taskId
) {
    return request({
        url:
            `/car/workflow/task/${taskId}/detail`,
        method:
            'get'
    })
}

二百零七、claim

export function claimTask(
    taskId
) {
    return request({
        url:
            `/car/workflow/task/${taskId}/claim`,
        method:
            'post'
    })
}

二百零八、unclaim

export function unclaimTask(
    taskId
) {
    return request({
        url:
            `/car/workflow/task/${taskId}/unclaim`,
        method:
            'post'
    })
}

二百零九、审批

export function approveTask(
    taskId,
    data
) {
    return request({
        url:
            `/car/workflow/task/${taskId}/approve`,
        method:
            'post',
        data
    })
}

二百一十、前端领取

function handleClaim(
    row
) {

    proxy.$modal
        .confirm(
            `确认领取任务“${row.taskName}”吗?`
        )
        .then(
            () =>
                claimTask(
                    row.taskId
                )
        )
        .then(
            () => {

                proxy.$modal
                    .msgSuccess(
                        '领取成功'
                    )

                getList()
            }
        )
}

二百一十一、为什么领取后要刷新

任务:

assignee

已经变化。

按钮也要:

从“领取”变成“审批”

二百一十二、前端审批表单

const approvalForm =
    ref({
        approved: undefined,
        comment: ''
    })

二百一十三、通过

function handleApprove() {

    submitApproval(
        true
    )
}

二百一十四、拒绝

function handleReject() {

    if (
        !approvalForm.value.comment
            ?.trim()
    ) {

        proxy.$modal
            .msgWarning(
                '拒绝时必须填写审批意见'
            )

        return
    }

    submitApproval(
        false
    )
}

二百一十五、提交

function submitApproval(
    approved
) {

    approveTask(
        currentTask.value.taskId,
        {
            approved,
            comment:
                approvalForm.value.comment
        }
    ).then(
        () => {

            proxy.$modal
                .msgSuccess(
                    approved
                        ? '审批通过'
                        : '审批拒绝'
                )

            drawerVisible.value =
                false

            getList()
        }
    )
}

二百一十六、前端通过按钮也要权限

<el-button
    v-hasPermi="[
        'car:workflow:task:approve'
    ]"
    type="primary"
    @click="handleApprove"
>
    通过
</el-button>

二百一十七、拒绝同样

同一个 approve 权限

因为:

它们都是审批动作

二百一十八、是否要拆 approve/reject 两个权限

课程:

不用

企业特殊场景:

可以

二百一十九、审批时间线组件

Element Plus:

el-timeline

非常合适。


二百二十、Vue 示例

<el-timeline>
    <el-timeline-item
        v-for="item in timeline"
        :key="
            item.nodeId
            + item.time
        "
        :timestamp="
            item.time
        "
        placement="top"
    >
        <div>
            <strong>
                {{ item.nodeName }}
            </strong>
        </div>

        <div>
            {{ item.operatorName }}
            ·
            {{ actionText(item.action) }}
        </div>

        <div
            v-if="item.comment"
        >
            {{ item.comment }}
        </div>
    </el-timeline-item>
</el-timeline>

二百二十一、时间线颜色要不要根据状态

可以。

但:

当前先保证信息正确

UI 后续再优化。


二百二十二、不要把时间线数据全靠前端拼

后端:

统一组装

前端:

只展示

二百二十三、为什么

审批记录规则:

属于业务逻辑

例如:

提交节点
审批节点
最终结果

后端更清楚。


二百二十四、已办详情

任务已结束:

TaskService 查不到

所以不能复用:

运行 Task detail

二百二十五、建议已办详情接口

GET /car/workflow/history/task/{taskId}/detail

二百二十六、后端

HistoricTaskInstance
↓
HistoricProcessInstance
↓
businessKey
↓
SettlementDetailVO
↓
ApprovalTimeline

二百二十七、为什么 todo detail 和 done detail 可以共用 VO

最终展示内容:

很类似

只是任务来源:

Runtime vs History

二百二十八、Controller 可以分接口

Service 内:

最终都组装 WorkflowApprovalDetailVO

二百二十九、历史 Task 是否一定有 assignee

如果任务从未被领取就被异常删除:

不一定

正常审批流程:

应该有

二百三十、审批历史里的审批人

业务页面优先:

car_approval_record.operatorUserId

更可信。


二百三十一、审批意见与 Activiti Comment

为什么两边都写:

引擎历史
+
业务历史

二百三十二、如果其中一个写失败怎么办

因为同事务:

整体回滚

前提:

同一事务管理器

二百三十三、审批记录和 task complete 顺序

推荐:

先校验
↓
addComment
↓
complete
↓
insert approvalRecord
↓
update settlement
↓
commit

二百三十四、为什么不是先插 ApprovalRecord

如果:

Task complete 失败

虽然事务可以回滚,

但业务逻辑上:

还是先完成流程动作
再确认业务记录

更自然。


二百三十五、并发审批

A 用户已经 claim。

A 浏览器:

双击通过

第一个:

完成任务

第二个:

task == null

返回:

任务已处理

二百三十六、两个用户同时 claim

Activiti claim:

应有任务占用检查

最终:

只能一个成为 assignee

业务层:

也先检查

二百三十七、为什么不要自己 UPDATE assignee

还是:

不要直接改 ACT_RU_TASK

使用:

TaskService.claim

二百三十八、unclaim 后审批记录怎么办

如果:

尚未 approve/reject

没有审批记录。


二百三十九、是否要记录领取历史

如果需要:

可以单独 workflow operation log

当前:

不需要

二百四十、审批意见安全

comment:

用户输入

前端展示:

使用 {{ }}

不要:

v-html

二百四十一、为什么

防止:

XSS

二百四十二、审批意见长度

数据库:

VARCHAR(1000)

DTO:

@Size(max = 1000)

两边统一。


二百四十三、如果意见可能很长

可以:

TEXT

当前:

1000 足够

二百四十四、我的待办排序

建议:

任务创建时间 DESC

最近:

在前

二百四十五、也可以最老优先

审批 SLA 场景:

最老优先

当前课程:

按最新优先

二百四十六、是否需要超时提醒

不是本章核心。

后续企业系统:

可以

当前:

不加

二百四十七、审批详情中的业务数据是否实时

结算提交后:

金额冻结

所以实时查业务表:

就是审批稳定数据

二百四十八、如果业务允许重开修改

新流程:

应该重新提交

旧历史:

保留

二百四十九、不要在同一个运行流程中随意改金额

否则:

审计混乱

二百五十、流程审核信息与日志区别

sys_oper_log:

系统操作日志

car_approval_record:

业务审批记录

ACT_HI_*:

流程引擎历史

二百五十一、三者不要混淆

系统日志
≠
业务审批记录
≠
流程引擎历史

二百五十二、哪个给业务用户看

主要:

car_approval_record

二百五十三、哪个给管理员排错

ACT_HI_*

二百五十四、哪个给系统审计

sys_oper_log

二百五十五、流程任务菜单

建议:

流程管理
├─ 流程定义
├─ 我的待办
└─ 我的已办

二百五十六、权限码

car:workflow:task:list

car:workflow:task:query

car:workflow:task:claim

car:workflow:task:unclaim

car:workflow:task:approve

car:workflow:history:list

二百五十七、为什么 query 和 list 分开

list:

看列表

query:

看详情

符合若依风格。


二百五十八、审批人角色

经理:

roleKey = store_manager

权限:

task:list
task:query
task:claim
task:unclaim
task:approve

二百五十九、财务:

roleKey = finance

同样:

task 审批权限

二百六十、普通服务顾问

可能只有:

查看自己提交的业务

不一定拥有:

审批权限

二百六十一、管理员能否审批所有任务

不要默认。

系统管理员:

有管理权限

不等于:

自动属于所有 candidateGroup

二百六十二、为什么

审批权限是:

业务职责

不是:

技术超级权限

二百六十三、如果管理员需要代办

应该:

明确设计管理员代办/转办能力

当前:

不做

二百六十四、流程历史列表接口

GET /car/workflow/task/done

二百六十五、已办查询 Service

示意:

public List<WorkflowDoneTaskVO>
    selectDoneTasks(
        WorkflowTaskQueryDTO query
    ) {

    String currentUserId =
            String.valueOf(
                SecurityUtils
                    .getUserId()
            );

    List<HistoricTaskInstance> tasks =
            historyService
                .createHistoricTaskInstanceQuery()
                .taskAssignee(
                    currentUserId
                )
                .finished()
                .orderByTaskCreateTime()
                .desc()
                .list();

    return tasks
        .stream()
        .map(
            this::toDoneTaskVO
        )
        .toList();
}

排序 API:

以当前实际 Activiti 版本为准

二百六十六、toDoneTaskVO

需要:

HistoricTask
↓
HistoricProcess
↓
businessKey
↓
Settlement
↓
ApprovalRecord

二百六十七、ApprovalRecord 根据 taskId 查

SELECT *
FROM car_approval_record
WHERE task_id = #{taskId}
LIMIT 1;

二百六十八、为什么 taskId 适合做关联

一条人工审批任务:

对应一次审批动作

二百六十九、要不要给 task_id 加索引

推荐:

ALTER TABLE car_approval_record
ADD KEY idx_approval_task_id (
    task_id
);

二百七十、是否加 UNIQUE(task_id)

如果规定:

每个 Task 只能产生一条最终审批记录

可以:

UNIQUE

二百七十一、课程推荐

UNIQUE KEY uk_approval_task (
    task_id
)

二百七十二、为什么

双击审批即使业务出现异常:

数据库仍防止重复审批记录

二百七十三、但 Claim 不写 ApprovalRecord

所以:

一个 Task 一条最终记录

成立。


二百七十四、审批记录唯一约束与 Task 完成

共同构成:

流程幂等保护

二百七十五、审批历史时间线查询

可以优先:

car_approval_record

因为:

展示更直接

二百七十六、如果某个历史任务没有 ApprovalRecord

可能说明:

旧数据
异常数据
系统迁移数据

页面可以:

HistoryService 兜底

二百七十七、当前课程要不要做复杂兜底

不需要。

先保证:

新流程记录完整

二百七十八、流程异常诊断

如果用户说:

我的待办没有数据

检查顺序:

1. 流程是否启动
2. ACT_RU_TASK 是否有任务
3. task candidateGroup
4. roleKey 是否匹配
5. 是否被别人 claim
6. 当前 userId
7. DataScope
8. Controller 权限

二百七十九、待办为空常见原因 1

BPMN:

candidateGroups = finance

若依:

roleKey = financial

不一致。


二百八十、原因 2

当前用户只有:

roleName = 财务

但:

roleKey 不对

二百八十一、原因 3

任务已被别人领取。


二百八十二、原因 4

流程其实已经结束。


二百八十三、原因 5

用户功能权限:

没有 task:list

二百八十四、原因 6

业务 DataScope:

过滤掉了

二百八十五、审批按钮 403

检查:

@PreAuthorize

sys_menu.perms

角色菜单授权

/getInfo permissions

前端缓存

二百八十六、claim 报“已被领取”

这是:

正常并发结果

不要当系统崩溃。


二百八十七、审批时报“任务不存在或已处理”

可能:

另一窗口已经提交

前端:

刷新列表

即可。


二百八十八、审批后业务状态没变

检查:

Task complete 后
updateApprovalStatus

有没有执行。


二百八十九、业务状态变了但 Task 还在

可能:

业务表先 update
taskService.complete 失败

事务:

没统一

二百九十、ApprovalRecord 没数据

检查:

saveApprovalRecord
事务
Mapper
taskId

二百九十一、已办查不到

检查:

History level
taskAssignee
finished()
流程是否实际完成该任务

二百九十二、审批意见查不到

检查:

car_approval_record

和:

TaskService comment

写入逻辑。


二百九十三、审批时间线顺序错

检查:

create_time
approval_record_id

排序。


二百九十四、用户离职/改名后历史审批人显示问题

只保存 userId:

JOIN 当前用户名

会显示:

当前姓名

如果要求历史快照:

增加 operator_name_snapshot

二百九十五、课程第一版不必加

先:

userId

二百九十六、历史任务分页

任务很多时:

HistoryQuery.listPage

比:

全查到内存

更好。


二百九十七、为什么本课程暂时可以简单

审批数据量:

很小

先理解:

运行与历史关系

二百九十八、流程审批是否要使用 Redis 锁

第一版:

不需要

二百九十九、为什么

Activiti Task:

本身有任务状态

加上:

claim
complete
数据库事务
@RepeatSubmit
ApprovalRecord UNIQUE

已经有较完整保护。


三百、不要遇到并发就先加分布式锁

这是很重要的项目习惯。

先用:

数据库约束
状态机
事务
引擎原子操作

三百零一、什么时候考虑 Redis Lock

真正:

跨多个服务
没有统一数据库约束
高并发资源竞争

才考虑。


三百零二、流程审核信息接口总览

GET
/car/workflow/task/todo

GET
/car/workflow/task/done

GET
/car/workflow/task/{taskId}/detail

POST
/car/workflow/task/{taskId}/claim

POST
/car/workflow/task/{taskId}/unclaim

POST
/car/workflow/task/{taskId}/approve

GET
/car/workflow/history/{processInstanceId}

三百零三、前端目录总览

src/views/car/workflow/
├─ definition/
│  └─ index.vue
│
└─ task/
   ├─ todo.vue
   ├─ done.vue
   └─ components/
      ├─ ApprovalDrawer.vue
      └─ ApprovalTimeline.vue

三百零四、菜单

流程管理
├─ 流程定义
├─ 我的待办
└─ 我的已办

三百零五、为什么我的待办和已办分开

用户工作习惯:

待办 = 我要处理

已办 = 我处理过

比一个页面混在一起:

更清楚

三百零六、审批详情不要新开一堆页面

课程:

Drawer

足够。


三百零七、为什么前端不直接查询 Activiti REST API

项目应该:

经过自己的 SpringBoot 后端

三百零八、原因

后端要做:

若依登录
权限
角色
DataScope
业务关联
字段裁剪

三百零九、不能绕过业务层

Activiti:

只是流程引擎

业务 API:

仍由淘车湾后端提供

三百一十、Git 提交建议

待办:

git commit -m "feat: add workflow todo tasks"

三百一十一、领取:

git commit -m "feat: add task claim and unclaim"

三百一十二、审批:

git commit -m "feat: add workflow approval actions"

三百一十三、审批记录:

git commit -m "feat: persist business approval records"

三百一十四、已办:

git commit -m "feat: add workflow completed tasks"

三百一十五、时间线:

git commit -m "feat: add approval timeline UI"

三百一十六、Cursor 提示词:待办查询

当前项目是 RuoYi-Vue SpringBoot3 + 淘车湾业务 + Activiti 工作流。

请先阅读真实 LoginUser、SysRole、WorkflowTaskService 和 BPMN。

实现“我的待办”。

要求:
1. 当前用户 ID 从 SecurityUtils 获取
2. 当前角色使用 sys_role.role_key
3. 查询当前用户 assignee 任务
4. 查询当前用户 candidateGroup 任务
5. 合并后按 taskId 去重
6. 不维护第二套 Activiti 用户体系
7. 从 Task.processInstanceId 获取 ProcessInstance
8. 从 businessKey 推导 settlementId
9. 组装 settlementNo 等业务信息
10. 最终业务数据仍做 DataScope 校验
11. 不直接查询/修改 ACT_* 表实现业务

三百一十七、Cursor 提示词:claim

请实现任务 claim/unclaim。

claim 要求:
1. task 必须存在
2. assignee 必须为空
3. 当前用户必须属于 task candidateGroup
4. candidateGroup 与 sys_role.role_key 对应
5. 检查对应结算 DataScope
6. userId 从 SecurityUtils 获取
7. 调用 TaskService.claim
8. 不允许前端传 userId

unclaim 要求:
1. task 必须存在
2. task.assignee 必须等于当前用户
3. 调用 TaskService.unclaim
4. 不允许取消别人领取的任务

三百一十八、Cursor 提示词:审批动作

请实现结算审批。

前端只传:
approved
comment

taskId 放 PathVariable。

后端:
1. 根据 taskId 查运行 Task
2. task 不存在提示已处理
3. 必须 task.assignee == 当前用户
4. 从 task.processInstanceId 查 ProcessInstance
5. 从 businessKey 推导 settlementId
6. 不信任前端 businessId
7. 校验 Settlement approvalStatus=PROCESSING
8. 校验业务 DataScope
9. reject 时 comment 必填
10. 根据 taskDefinitionKey 决定 managerApproved/financeApproved
11. 不允许前端传流程变量名
12. addComment
13. TaskService.complete
14. 保存 car_approval_record
15. 拒绝同步 REJECTED
16. 通过后流程结束同步 APPROVED,否则 PROCESSING
17. 全部同一事务

三百一十九、Cursor 提示词:已办

请实现“我的已办”。

要求:
1. 使用 HistoryService
2. 查询 taskAssignee=当前 userId
3. 只查询 finished 历史任务
4. 从 HistoricTaskInstance 获取 processInstanceId
5. 使用 HistoricProcessInstance 获取 businessKey
6. businessKey 转 settlementId
7. 根据 taskId 查询 car_approval_record
8. 返回 action/comment/operatorName
9. 点击业务详情仍做 DataScope 校验
10. 不用 TaskService 查询已经完成的历史任务

三百二十、Cursor 提示词:审批时间线

请实现结算审批时间线。

数据:
car_settlement
car_approval_record
sys_user

要求:
1. 第一项显示结算提交
2. 后续显示经理/财务审批
3. 展示节点名、审批人、APPROVE/REJECT、意见、时间
4. 按 create_time + approval_record_id 升序
5. 前端使用 Element Plus el-timeline
6. comment 使用普通文本插值,不使用 v-html
7. 不把 ACT_HI_* 原始网关节点直接展示给普通业务用户

三百二十一、Cursor 提示词:流程权限审查

请审查当前工作流任务安全。

检查:
1. 是否只有前端 v-hasPermi 没后端 @PreAuthorize
2. claim 是否允许前端传 userId
3. approve 是否检查 task.assignee
4. candidateGroup 是否和 roleKey 匹配
5. 是否直接信任 settlementId
6. 是否从 businessKey 反查业务
7. 是否检查结算 DataScope
8. 是否允许其他门店同角色审批
9. 是否存在已完成 task 重复审批
10. ApprovalRecord 是否可能重复写入
11. 是否对 task_id 建唯一约束
12. 是否直接 UPDATE ACT_RU_TASK

三百二十二、IDEA 调试重点

断点:

selectTodoTasks

getCurrentRoleKeys

isCandidate

claimTask

unclaimTask

approveTask

resolveSettlementId

saveApprovalRecord

selectDoneTasks

buildTimeline

三百二十三、第一次调试待办

数据库先看:

ACT_RU_TASK

确认:

确实有 managerApprove

三百二十四、然后看当前用户

roleKey

是否:

store_manager

三百二十五、待办仍没有

检查:

candidateGroup

三百二十六、经理领取

观察:

task.assignee

从:

null

变:

当前 userId

三百二十七、审批通过

观察:

当前 Task 消失

新任务:

financeApprove

出现。


三百二十八、同时检查

car_approval_record

新增:

经理 APPROVE

三百二十九、财务通过

观察:

Runtime ProcessInstance 消失

三百三十、History

确认:

两个 HistoricTaskInstance

都完成。


三百三十一、Settlement

确认:

approvalStatus = APPROVED

三百三十二、测试用例 1

经理账号:

能看到 managerApprove

三百三十三、测试用例 2

财务账号:

经理节点时看不到任务

三百三十四、测试用例 3

经理领取:

成功

三百三十五、测试用例 4

另一经理同时领取:

失败

三百三十六、测试用例 5

领取人取消领取:

成功

三百三十七、测试用例 6

其他人 unclaim:

失败

三百三十八、测试用例 7

经理未 claim 直接 approve:

失败

三百三十九、测试用例 8

经理 claim 后 approve:

成功

三百四十、测试用例 9

经理通过:

财务任务出现

三百四十一、测试用例 10

经理拒绝且无 comment:

失败

三百四十二、测试用例 11

经理拒绝有 comment:

成功
流程结束

三百四十三、测试用例 12

财务通过:

approvalStatus = APPROVED

三百四十四、测试用例 13

财务拒绝:

approvalStatus = REJECTED

三百四十五、测试用例 14

同一 task 双击 approve:

只有一次成功

三百四十六、测试用例 15

ApprovalRecord:

taskId 唯一

三百四十七、测试用例 16

Task 完成后:

TaskService 查不到

但:

HistoryService 能查到

三百四十八、测试用例 17

当前审批人已办:

能看到自己完成的任务

三百四十九、测试用例 18

已办详情:

能显示结算详情 + 审批记录

三百五十、测试用例 19

沈阳财务尝试处理大连结算:

即使 roleKey 相同
也因 DataScope 拒绝

三百五十一、测试用例 20

历史时间线:

提交
经理审批
财务审批

顺序正确。


三百五十二、数据库检查:审批记录

SELECT
    approval_record_id,
    business_id,
    process_instance_id,
    task_id,
    activity_id,
    activity_name,
    operator_user_id,
    action,
    comment,
    create_time
FROM car_approval_record
ORDER BY
    approval_record_id DESC;

三百五十三、检查重复 task

SELECT
    task_id,
    COUNT(*)
FROM car_approval_record
GROUP BY task_id
HAVING COUNT(*) > 1;

应该:

0 行

三百五十四、运行任务

SELECT
    ID_,
    NAME_,
    TASK_DEF_KEY_,
    ASSIGNEE_,
    PROC_INST_ID_,
    CREATE_TIME_
FROM ACT_RU_TASK;

字段:

以当前版本真实表结构为准

三百五十五、历史任务

SELECT
    ID_,
    NAME_,
    ASSIGNEE_,
    PROC_INST_ID_,
    START_TIME_,
    END_TIME_
FROM ACT_HI_TASKINST
ORDER BY
    START_TIME_ DESC;

三百五十六、注意

这些 SQL:

只用于学习和诊断

不要:

业务代码直接操作 ACT_* 表

三百五十七、面试题 1:我的待办为什么不能只查 assignee

答:

候选组任务在被领取之前 assignee 通常为空。

如果只查询 taskAssignee(currentUser),
用户看不到自己有资格领取的 CandidateGroup 任务。

所以待办通常需要同时考虑候选任务和已领取任务。

三百五十八、面试题 2:为什么 claim 后其他候选人不能审批

答:

claim 会把任务明确分配给某个 assignee。

一旦任务已被用户 A 领取,
虽然用户 B 可能仍属于同一个候选角色,
但该任务已经有明确处理人。

审批时应检查 task.assignee 是否等于当前用户,
防止其他候选人越权处理。

三百五十九、面试题 3:为什么前端不能传领取 userId

答:

userId 属于当前登录身份信息。

如果允许客户端传 userId,
用户可以把任务领取给其他人,
甚至构造非法身份。

因此领取人必须由后端 SecurityUtils
从当前登录用户中获取。

三百六十、面试题 4:TaskService 和 HistoryService 区别

答:

TaskService 主要用于当前运行中的任务,
例如待办、领取、完成任务。

HistoryService 用于查询已经发生过的流程历史,
例如已完成任务、历史流程实例和历史活动节点。

任务完成后,不能再依赖 TaskService 查询它,
应通过 HistoryService 查看历史。

三百六十一、面试题 5:为什么还要 car_approval_record

答:

Activiti History 保存的是流程引擎历史,
但业务页面通常需要明确显示审批动作、
审批意见、审批人和业务 ID。

car_approval_record 可以保存业务友好的审批快照,
方便时间线、报表和审计展示。

流程历史和业务审批记录是互补关系。

三百六十二、面试题 6:为什么不能只靠 roleKey 判断审批权限

答:

roleKey 只能说明用户属于某个候选审批角色。

任务可能已经被别人 claim,
而且同一角色可能分布在不同门店。

因此完整审批权限还要检查:
功能权限、Task assignee/candidate 和业务 DataScope。

三百六十三、面试题 7:为什么 businessId 不信前端

答:

客户端可以故意传一个合法 taskId,
再配一个其他结算单的 businessId。

正确做法是根据 taskId 找到 processInstanceId,
再从流程实例 businessKey 推导 settlementId,
保证任务和业务数据天然对应。

三百六十四、面试题 8:为什么审批拒绝必须写原因

答:

被拒绝的业务通常需要申请人知道整改原因,
否则无法判断下一步如何处理。

因此拒绝动作应要求填写审批意见,
并同时保存到业务审批记录和流程评论中。

三百六十五、面试题 9:为什么已办要查 HistoricProcessInstance

答:

已办任务对应的流程可能已经完全结束。

流程结束后 RuntimeService
可能查不到运行中的 ProcessInstance。

因此已办需要从 HistoricTaskInstance
关联 HistoricProcessInstance,
再通过 businessKey 找业务数据。

三百六十六、面试题 10:为什么审批时间线不直接展示全部 HistoricActivity

答:

HistoricActivity 可能包含网关、开始事件、结束事件等引擎节点。

这些信息对流程诊断和流程图高亮很有价值,
但普通业务用户更关心提交、审批人、审批结果和意见。

因此业务时间线通常使用更友好的 ApprovalRecord,
流程诊断再使用完整 HistoricActivity。

三百六十七、面试题 11:为什么 taskId 建议在审批记录里唯一

答:

一个人工审批 Task 正常情况下只会形成一次最终审批动作。

对 taskId 建唯一约束,
可以防止异常重试或重复请求写入两条审批结果,
作为业务幂等的数据库兜底。

三百六十八、面试题 12:为什么不一定需要 Redis 分布式锁

答:

工作流任务本身有运行状态,
claim 会建立明确 assignee,
complete 后任务会结束。

结合 Spring 事务、@RepeatSubmit、
TaskService 原子操作和数据库唯一约束,
课程单体项目通常已经足够。

不应该一遇到并发就先加分布式锁。

三百六十九、面试题 13:审批功能为什么需要 DataScope

答:

CandidateGroup 只表示角色身份,
例如多个门店都有 finance 角色。

如果不做业务 DataScope,
沈阳财务可能审批大连门店结算。

所以流程角色权限和业务数据权限必须同时校验。

三百七十、面试题 14:为什么审批后结算金额不能再修改

答:

审批动作针对的是一份确定的业务数据。

如果审批完成或审批进行中仍能修改金额,
审批人审核的数据和最终结算数据就会不一致。

因此结算提交后应冻结金额,
需要修改时走明确的驳回和重提流程。

三百七十一、面试题 15:为什么已办和待办通常分页面

答:

待办表示当前仍需要用户处理的任务,
已办表示用户过去已经完成的任务。

两者数据源、状态和用户目标都不同,
分开页面更符合业务使用习惯,
查询逻辑也更清晰。

三百七十二、本章知识树

Workflow Review
│
├─ Todo
│  ├─ CandidateGroup
│  ├─ Assignee
│  ├─ RoleKey
│  ├─ Merge
│  └─ Deduplicate
│
├─ Claim
│  ├─ Candidate Validation
│  ├─ Current User
│  ├─ TaskService.claim
│  └─ TaskService.unclaim
│
├─ Approval
│  ├─ taskId
│  ├─ assignee check
│  ├─ businessKey
│  ├─ DataScope
│  ├─ approved
│  ├─ comment
│  └─ complete
│
├─ Business Record
│  ├─ car_approval_record
│  ├─ activityId
│  ├─ action
│  ├─ operator
│  └─ comment
│
├─ History
│  ├─ HistoricTaskInstance
│  ├─ HistoricProcessInstance
│  ├─ HistoricActivityInstance
│  └─ Done Tasks
│
├─ Vue
│  ├─ todo.vue
│  ├─ done.vue
│  ├─ ApprovalDrawer
│  └─ ApprovalTimeline
│
└─ Security
   ├─ @PreAuthorize
   ├─ Task Identity
   ├─ DataScope
   └─ No Frontend BusinessId Trust

三百七十三、完整待办链路

LoginUser
↓
userId + roleKeys
↓
TaskService
├─ Assignee Tasks
└─ CandidateGroup Tasks
↓
Merge + Deduplicate
↓
ProcessInstance
↓
businessKey
↓
Settlement
↓
DataScope
↓
WorkflowTaskVO
↓
Vue Todo

三百七十四、完整审批链路

Vue
taskId + approved + comment
↓
@PreAuthorize
↓
TaskService query
↓
Check assignee
↓
ProcessInstance
↓
businessKey
↓
Settlement
↓
DataScope
↓
Check approvalStatus
↓
Resolve variable by taskDefinitionKey
↓
Add Comment
↓
Complete Task
↓
ApprovalRecord
↓
Check Process Running
↓
PROCESSING / APPROVED / REJECTED
↓
Commit

三百七十五、完整已办链路

Current User
↓
HistoryService
↓
HistoricTaskInstance finished
↓
HistoricProcessInstance
↓
businessKey
↓
Settlement
↓
ApprovalRecord by taskId
↓
WorkflowDoneTaskVO
↓
Vue Done

三百七十六、完整审批时间线

Settlement Submit
↓
ApprovalRecord:
Manager APPROVE/REJECT
↓
ApprovalRecord:
Finance APPROVE/REJECT
↓
Final Result

三百七十七、本章最终验收

你应该能够独立完成:

1. 我的待办

2. CandidateGroup 任务

3. Assignee 任务

4. roleKey 提取

5. 待办合并去重

6. Claim

7. Unclaim

8. Claim 权限校验

9. Assignee 权限校验

10. 审批详情

11. SettlementDetailVO 复用

12. 通过

13. 拒绝

14. 拒绝意见必填

15. taskDefinitionKey → 流程变量

16. TaskService.complete

17. Activiti Comment

18. car_approval_record

19. taskId 唯一约束

20. approvalStatus 同步

21. 我的已办

22. HistoricTaskInstance

23. HistoricProcessInstance

24. HistoricActivityInstance

25. businessKey 历史反查

26. Approval Timeline

27. Vue Todo 页面

28. Vue Done 页面

29. ApprovalDrawer

30. el-timeline

31. 功能权限

32. Task 身份权限

33. DataScope

34. 防止跨门店审批

35. 防止重复审批

36. 不直接操作 ACT_* 表

三百七十八、本章最重要的工程原则

1. TaskService 看当前待办,HistoryService 看已办历史

2. CandidateGroup 表示有资格,Assignee 表示已经明确归属

3. 候选任务先 claim 再审批,更容易控制并发

4. claim 的 userId 必须来自当前登录用户

5. 审批必须检查 task.assignee

6. roleKey 权限不能替代 DataScope

7. taskId 不能直接信任,必须验证任务存在和归属

8. businessId 不要由前端决定,要从 businessKey 反查

9. car_approval_record 用于业务友好的审批历史

10. Activiti History 用于引擎历史和流程诊断

11. Task 完成后 TaskService 查不到是正常现象

12. 已办要使用 HistoricTaskInstance

13. 流程结束后业务 ID 要从 HistoricProcessInstance.businessKey 获取

14. 审批拒绝必须记录原因

15. 审批提交和业务状态更新要保持事务一致性

16. 审批时间线不要机械展示所有网关和内部节点

17. 审批后的金额必须冻结

18. 不要一遇到并发就先加 Redis 分布式锁

三百七十九、下一篇

按照课程表,下一篇进入:

《淘车湾项目实战(八):我的待办/已办与流程图高亮分析及实现》

下一章重点会把本章的 History 数据进一步用于:

流程进度

当前节点

已执行节点

HistoricActivityInstance

BPMN XML

bpmn-js

已完成节点高亮

当前节点高亮

已走 SequenceFlow 高亮

审批详情中的流程图

ProcessDefinition Resource

流程实例运行中/已结束兼容

我的待办/已办页面进一步完善

完整项目审批闭环

版本提醒

本章使用的是 Activiti 经典流程引擎概念与 API 思路:

TaskService
HistoryService
RuntimeService
claim
unclaim
complete
HistoricTaskInstance
HistoricProcessInstance
HistoricActivityInstance

实际项目中:

具体 Query 方法名
排序方法
Starter
Spring Boot 兼容性

应以当前项目真实 Activiti 依赖为准。

特别是在:

Spring Boot 3 + JDK17

环境中,

不要机械复制:

旧 Spring Boot 2 / Activiti7 教程

应先看:

dependency tree
当前引擎 API
当前项目兼容性

再落代码。