淘车湾项目实战九_我的申请分析及代码实现

O泡李华 8

淘车湾项目实战(九):我的申请分析及代码实现

本章位置:第三阶段 Java 企业项目 + AI 助手
对应课程:淘车湾项目实战——“我的一般”分析及代码实现
说明:结合前后课程内容,本章按“我的申请 / 我发起的流程”来实现。
前置知识:淘车湾项目实战(一)~(八)、Activiti7、我的待办、我的已办、流程图高亮、若依权限、DataScope、Vue3、Element Plus
后续衔接:项目总结、Bug 调试、项目优化
学习目标:补全流程发起人视角,完成“我的申请”查询、审批状态查看、审批意见、流程图、被拒绝后的处理、重新提交,以及与待办/已办之间的完整闭环。


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

前面已经实现:

我的待办

用于:

审批人看自己要处理什么

也实现:

我的已办

用于:

审批人看自己处理过什么

但申请人还缺一个入口:

我提交过哪些结算审批?

所以本章实现:

我的申请

二、三类页面一定要区分

这是本章最重要的第一个知识点。

我的申请
=
我发起的业务

我的待办
=
现在轮到我审批

我的已办
=
我以前审批过

三、举例

服务顾问小王:

创建结算单
↓
提交审批

那么:

小王
→ 我的申请

经理老李:

领取经理审批
↓
处理

那么:

老李
→ 我的待办 / 我的已办

财务小张:

处理财务审批

那么:

小张
→ 我的待办 / 我的已办

四、为什么不能把“我的申请”和“我的已办”混一起

因为角色完全不同。

我的申请关心:

我提交的业务现在怎么样了

我的已办关心:

我处理过哪些别人的任务

五、“我的申请”数据主要来自哪里

最适合:

car_settlement

因为我们前面已经建议增加:

applicant_user_id
submit_time

六、为什么不直接从 Activiti History 查“我的申请”

可以查:

流程发起人

但业务页面更适合:

业务表

原因:

业务编号
金额
客户
车辆
门店
审批状态
支付状态

这些都在:

业务表

七、推荐 car_settlement 补充字段

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

八、applicant_user_id 是什么

表示:

谁正式提交了这张结算审批

九、为什么不是 create_by

因为:

创建草稿的人

不一定等于:

最后提交审批的人

十、举例

小王:

创建草稿

后来小李:

修改并提交

那么:

create_by = 小王
applicant_user_id = 小李

十一、submit_time 为什么也要保存

创建时间:

草稿产生时间

提交时间:

正式进入审批流程时间

这两个:

业务含义不同

十二、提交时写申请人

Long currentUserId =
        SecurityUtils.getUserId();

settlement.setApplicantUserId(
    currentUserId
);

settlement.setSubmitTime(
    LocalDateTime.now()
);

十三、为什么 applicantUserId 不让前端传

和之前一样:

身份信息
必须从登录态获取

前端传:

不可信

十四、我的申请查询条件

推荐:

结算编号

工单编号

车牌号

审批状态

支付状态

结算状态

提交时间

十五、MyApplicationQueryDTO

public class MyApplicationQueryDTO {

    private String settlementNo;

    private String workOrderNo;

    private String plateNo;

    private String approvalStatus;

    private String paymentStatus;

    private String status;

    private LocalDateTime beginSubmitTime;

    private LocalDateTime endSubmitTime;
}

十六、为什么不需要 applicantUserId

因为:

后端自动使用当前登录用户

十七、错误做法

query.setApplicantUserId(
    request.getApplicantUserId()
);

如果前端能传:

别人的 userId

就可能:

查别人的申请

十八、正确做法

Service:

Long userId =
        SecurityUtils.getUserId();

然后:

强制 applicant_user_id = 当前 userId

十九、我的申请 VO

public class MyApplicationVO {

    private Long settlementId;

    private String settlementNo;

    private String workOrderNo;

    private String customerName;

    private String plateNo;

    private String deptName;

    private BigDecimal totalAmount;

    private BigDecimal discountAmount;

    private BigDecimal receivableAmount;

    private String approvalStatus;

    private String paymentStatus;

    private String status;

    private String processInstanceId;

    private LocalDateTime submitTime;

    private LocalDateTime createTime;
}

二十、为什么返回 processInstanceId

前端点击:

查看流程进度

会用到。


二十一、为什么不是直接返回 TaskId

申请人:

不负责审批

所以:

不需要 TaskId

二十二、列表 SQL

<select
    id="selectMyApplications"
    resultType="...MyApplicationVO"
>
    SELECT
        s.settlement_id,
        s.settlement_no,
        wo.work_order_no,
        c.customer_name,
        v.plate_no,
        d.dept_name,
        s.total_amount,
        s.discount_amount,
        s.receivable_amount,
        s.approval_status,
        s.payment_status,
        s.status,
        s.process_instance_id,
        s.submit_time,
        s.create_time
    FROM car_settlement s
    JOIN car_work_order wo
      ON wo.work_order_id =
         s.work_order_id
    JOIN car_customer c
      ON c.customer_id =
         s.customer_id
    JOIN car_vehicle v
      ON v.vehicle_id =
         s.vehicle_id
    LEFT JOIN sys_dept d
      ON d.dept_id =
         s.dept_id
    WHERE
        s.applicant_user_id =
            #{applicantUserId}

    <if test="settlementNo != null
              and settlementNo != ''">
        AND s.settlement_no =
            #{settlementNo}
    </if>

    <if test="workOrderNo != null
              and workOrderNo != ''">
        AND wo.work_order_no =
            #{workOrderNo}
    </if>

    <if test="plateNo != null
              and plateNo != ''">
        AND v.plate_no
            LIKE CONCAT(
                '%',
                #{plateNo},
                '%'
            )
    </if>

    <if test="approvalStatus != null
              and approvalStatus != ''">
        AND s.approval_status =
            #{approvalStatus}
    </if>

    <if test="paymentStatus != null
              and paymentStatus != ''">
        AND s.payment_status =
            #{paymentStatus}
    </if>

    <if test="status != null
              and status != ''">
        AND s.status =
            #{status}
    </if>

    ORDER BY
        s.submit_time DESC,
        s.settlement_id DESC
</select>

二十三、Mapper 参数怎么传 applicantUserId

可以定义:

List<MyApplicationVO>
    selectMyApplications(
        @Param("query")
        MyApplicationQueryDTO query,

        @Param("applicantUserId")
        Long applicantUserId
    );

二十四、XML 中 query 字段

如果是这种 Mapper:

@Param("query")

那么 XML:

query.settlementNo

而不是:

settlementNo

二十五、示意

<if test="query.settlementNo != null
          and query.settlementNo != ''">
    AND s.settlement_no =
        #{query.settlementNo}
</if>

二十六、为什么参数命名要统一

MyBatis 很常见 Bug:

Parameter 'settlementNo' not found

就是:

@Param 和 XML 名称不一致

二十七、Service

public List<MyApplicationVO>
    selectMyApplications(
        MyApplicationQueryDTO query
    ) {

    Long currentUserId =
            SecurityUtils
                .getUserId();

    return settlementMapper
        .selectMyApplications(
            query,
            currentUserId
        );
}

二十八、这里还需要 DataScope 吗

“我的申请”本质上:

已经按 applicant_user_id = 当前用户

强制限制。

所以列表:

可以不再依赖 DataScope

二十九、为什么

这是:

本人所有权

查询。


三十、但详情呢

详情必须:

检查当前用户是不是申请人

或者:

当前用户是否有其他业务查看权限

三十一、我的申请详情推荐专门接口

GET
/car/workflow/application/{settlementId}

三十二、为什么不直接复用管理员 Settlement Detail

可以复用 Service 查询能力,

但:

授权逻辑不同

三十三、管理员详情

可能按:

DataScope

三十四、我的申请详情

应该按:

applicant_user_id = 当前 userId

三十五、Ownership 检查

private CarSettlement
    requireMyApplication(
        Long settlementId
    ) {

    Long userId =
            SecurityUtils
                .getUserId();

    CarSettlement settlement =
            settlementMapper
                .selectById(
                    settlementId
                );

    if (
        settlement == null
    ) {
        throw new ServiceException(
            "结算单不存在"
        );
    }

    if (
        !userId.equals(
            settlement
                .getApplicantUserId()
        )
    ) {
        throw new ServiceException(
            "无权查看该申请"
        );
    }

    return settlement;
}

三十六、为什么不能只靠前端隐藏按钮

用户可以:

手工改 URL

三十七、我的申请详情返回

可以复用:

SettlementDetailVO

再补:

ApprovalTimeline
Process Status

三十八、MyApplicationDetailVO

public class MyApplicationDetailVO {

    private SettlementDetailVO settlement;

    private List<ApprovalTimelineVO>
            timeline;

    private String processInstanceId;

    private String currentNodeName;

    private Boolean canResubmit;
}

三十九、为什么 canResubmit 后端返回

前端不要:

自己根据很多状态乱推

四十、后端根据业务规则判断

例如:

approvalStatus = REJECTED
+
paymentStatus = UNPAID
+
status != COMPLETED

才:

canResubmit = true

四十一、申请人最关心的几个状态

待审批

审批中

审批通过

审批拒绝

无需审批

四十二、前端应该把三个状态组合解释成人话

比如:

approvalStatus = PROCESSING
paymentStatus = UNPAID

页面可显示:

审批中

四十三、如果审批通过未支付

显示:

审批通过,待支付

四十四、如果已支付

显示:

已完成

四十五、为什么 UI 可以做“综合状态”

数据库:

三个状态字段

必须分开。

前端展示:

可以组合成用户更容易理解的文案

四十六、综合状态不要存数据库

因为:

它是展示结果

四十七、综合状态函数

后端:

private String resolveDisplayStatus(
        CarSettlement settlement
) {

    if (
        ApprovalStatus
            .REJECTED
            .getCode()
            .equals(
                settlement
                    .getApprovalStatus()
            )
    ) {
        return "审批拒绝";
    }

    if (
        ApprovalStatus
            .PROCESSING
            .getCode()
            .equals(
                settlement
                    .getApprovalStatus()
            )
    ) {
        return "审批中";
    }

    if (
        PaymentStatus
            .PAID
            .getCode()
            .equals(
                settlement
                    .getPaymentStatus()
            )
    ) {
        return "已完成";
    }

    if (
        ApprovalStatus
            .APPROVED
            .getCode()
            .equals(
                settlement
                    .getApprovalStatus()
            )
    ) {
        return "审批通过,待支付";
    }

    return "待处理";
}

四十八、是否一定要后端返回 displayStatus

可以。

但数据库:

不要新增 display_status

四十九、为什么

展示逻辑未来:

可以变

数据库核心状态:

保持稳定

五十、我的申请页面

建议:

src/views/car/workflow/application/index.vue

五十一、查询区

结算编号

工单编号

车牌号

审批状态

支付状态

提交时间

五十二、表格列

结算编号

工单编号

客户

车牌

门店

总金额

优惠金额

应收金额

审批状态

支付状态

提交时间

操作

五十三、操作

详情

查看流程

重新提交

五十四、重新提交什么时候显示

只有:

审批拒绝

且:

后端 canResubmit = true

五十五、为什么不要前端自己判断全部条件

规则可能以后增加:

是否支付

是否作废

是否有未结束流程

是否有权限

五十六、后端返回操作能力更稳

例如:

canViewProcess

canEdit

canResubmit

五十七、是否所有页面都这么设计

不一定。

但复杂业务:

状态多

时非常好用。


五十八、我的申请详情 Drawer

可以复用:

SettlementDetail
ApprovalTimeline
ProcessDiagram

五十九、推荐 Tabs

申请详情

审批记录

流程进度

六十、申请详情

显示:

结算主信息
结算明细
金额汇总

六十一、审批记录

显示:

提交
经理审批
财务审批

六十二、流程进度

显示:

bpmn-js 高亮流程图

六十三、为什么复用前面的组件

前面已经有:

ApprovalTimeline.vue

ProcessDiagram.vue

不要重复写。


六十四、Vue API

import request
    from '@/utils/request'

export function listMyApplications(
    query
) {
    return request({
        url:
            '/car/workflow/application/list',
        method:
            'get',
        params:
            query
    })
}

六十五、详情

export function getMyApplication(
    settlementId
) {
    return request({
        url:
            `/car/workflow/application/${settlementId}`,
        method:
            'get'
    })
}

六十六、重新提交

export function resubmitApplication(
    settlementId
) {
    return request({
        url:
            `/car/workflow/application/${settlementId}/resubmit`,
        method:
            'post'
    })
}

六十七、为什么 resubmit 不需要前端传金额

重新提交前:

先让用户修改草稿

真正 resubmit:

只是再次启动审批

六十八、审批拒绝后应该怎么处理

这是本章很重要的业务问题。

我们前面推荐:

approvalStatus = REJECTED

而不是自动:

DRAFT

六十九、申请人看到拒绝后

先:

查看拒绝原因

然后:

点击“重新编辑”

七十、重新编辑

把结算从:

REJECTED

恢复到:

DRAFT

七十一、为什么分成两个动作更清楚

驳回

不等于:

自动重新提交

七十二、推荐操作

重新编辑

重新提交

七十三、重新编辑接口

POST
/car/workflow/application/{id}/reopen

七十四、reopen 条件

当前用户是申请人

approvalStatus = REJECTED

paymentStatus = UNPAID

流程已结束

七十五、为什么流程必须已结束

如果 Activiti 还有 Runtime Task:

不能重新编辑

否则:

旧流程还在跑
新业务又变了

七十六、检查流程是否结束

如果:

processInstanceId != null

查询:

RuntimeService

七十七、如果 Runtime 还能查到

说明:

旧流程没结束

不允许 reopen。


七十八、reopen 后状态

status = DRAFT

approvalStatus = PENDING

或:

根据你的初始枚举
设置成待提交/待审批

七十九、processInstanceId 要不要清空

推荐:

重新编辑阶段可以清空当前 processInstanceId

因为旧流程:

已经结束

八十、那旧流程历史怎么找

通过:

car_approval_record

仍然有:

旧 processInstanceId

八十一、更完整设计

如果以后允许多次审批:

最好独立 business_process 表

八十二、当前课程简化

只维护:

最近一次 processInstanceId

历史:

ApprovalRecord + Activiti History

八十三、reopen Service

@Transactional(
    rollbackFor = Exception.class
)
public void reopen(
        Long settlementId
) {

    CarSettlement settlement =
            requireMyApplication(
                settlementId
            );

    if (
        !ApprovalStatus
            .REJECTED
            .getCode()
            .equals(
                settlement
                    .getApprovalStatus()
            )
    ) {
        throw new ServiceException(
            "只有审批拒绝的申请可以重新编辑"
        );
    }

    if (
        !PaymentStatus
            .UNPAID
            .getCode()
            .equals(
                settlement
                    .getPaymentStatus()
            )
    ) {
        throw new ServiceException(
            "已产生支付记录,不能重新编辑"
        );
    }

    ensureProcessEnded(
        settlement
            .getProcessInstanceId()
    );

    settlementMapper
        .reopenSettlement(
            settlementId,
            SettlementStatus
                .DRAFT
                .getCode(),
            ApprovalStatus
                .PENDING
                .getCode()
        );
}

八十四、为什么 reopen 后允许改优惠

因为:

重新回到草稿

可以:

根据拒绝意见
调整优惠

八十五、但仍不能修改什么

工单项目
单价
数量
原金额

八十六、为什么

这些:

属于工单冻结快照

八十七、重新编辑主要修改

明细优惠

备注

八十八、修改后重新提交

复用:

SettlementService.submitSettlement

八十九、不要再写一套 resubmit 流程启动代码

正确:

reopen
↓
edit
↓
submitSettlement

九十、为什么复用原 submit

这样:

金额一致性校验

是否需要审批

流程部署检查

businessKey

processInstanceId

全部复用。


九十一、如果用户点“重新提交”时还是 REJECTED

可以有两种 UI。

方案一:

先重新编辑

再提交。


九十二、方案二

如果不修改任何内容:

直接 resubmit

后端内部:

reopen + submit

九十三、课程推荐

方案一:

更清楚

九十四、为什么

被拒绝通常:

就是需要修改

九十五、我的申请中“审批进度”

申请人不需要:

claim

也不能:

approve

九十六、申请人流程图权限

只要:

该 settlement.applicant_user_id
=
当前用户

就能:

看自己流程

九十七、这里和审批人 DataScope 不同

审批人:

Task + DataScope

申请人:

Ownership

九十八、流程图 API 是否能复用

前面 Diagram API:

按 Settlement DataScope

如果普通申请人:

可能没有该门店 DataScope 权限

但又应该:

看自己申请

九十九、这就是授权模型差异

需要让流程图服务支持:

申请人本人
OR
有业务 DataScope 权限

一百、授权方法

private CarSettlement
    requireProcessViewPermission(
        Long settlementId
    ) {

    CarSettlement settlement =
            settlementMapper
                .selectById(
                    settlementId
                );

    if (
        settlement == null
    ) {
        throw new ServiceException(
            "业务数据不存在"
        );
    }

    Long userId =
            SecurityUtils
                .getUserId();

    if (
        userId.equals(
            settlement
                .getApplicantUserId()
        )
    ) {
        return settlement;
    }

    return requireAccessibleSettlement(
        settlementId
    );
}

一百零一、为什么这样

本人:

有所有权

审批人/管理员:

有业务权限

一百零二、不要简单改成“所有登录用户都能看流程图”

那会:

严重越权

一百零三、审批记录查看权限也一样

本人:

可以看自己申请的审批意见

审批人员:

按 DataScope / Task / History 权限

一百零四、我的申请列表分页

这里数据来自:

MyBatis

所以可以正常:

startPage();

一百零五、Controller

@PreAuthorize(
    "@ss.hasPermi('car:workflow:application:list')"
)
@GetMapping("/list")
public TableDataInfo list(
        MyApplicationQueryDTO query
) {

    startPage();

    List<MyApplicationVO> list =
            applicationService
                .selectMyApplications(
                    query
                );

    return getDataTable(
        list
    );
}

一百零六、为什么这里 PageHelper 能用

因为:

最终查询是 MyBatis Mapper SQL

和前面 TaskService:

不同

一百零七、这也是一个常考点

MyBatis 查询
→ PageHelper

Activiti Query
→ listPage / 自己分页

一百零八、我的申请权限码

建议:

car:workflow:application:list

car:workflow:application:query

car:workflow:application:reopen

一百零九、为什么 submit 不放 application 权限

提交动作:

仍然属于 settlement

可以复用:

car:settlement:submit

一百一十、普通服务顾问

权限:

application:list

application:query

settlement:add

settlement:edit

settlement:submit

一百一十一、流程定义权限

不需要。


一百一十二、审批权限

也不需要。


一百一十三、为什么

发起人和审批人:

职责不同

一百一十四、我的申请菜单

建议:

流程中心
├─ 我的申请
├─ 我的待办
└─ 我的已办

一百一十五、流程定义

管理员菜单:

流程管理
└─ 流程定义

一百一十六、为什么流程定义不一定和“我的”放一起

流程定义:

管理员配置

我的申请/待办/已办:

普通业务使用

一百一十七、前端状态 Tag

<dict-tag
    :options="car_approval_status"
    :value="row.approvalStatus"
/>

一百一十八、支付状态

<dict-tag
    :options="car_payment_status"
    :value="row.paymentStatus"
/>

一百一十九、审批拒绝行操作

<el-button
    v-if="row.canReopen"
    v-hasPermi="[
        'car:workflow:application:reopen'
    ]"
    link
    type="warning"
    @click="
        handleReopen(row)
    "
>
    重新编辑
</el-button>

一百二十、为什么 canReopen 建议后端返回

避免:

前端状态规则复制多份

一百二十一、列表 VO 增加

private Boolean canReopen;

一百二十二、后端组装

vo.setCanReopen(
    canReopen(
        settlement
    )
);

一百二十三、canReopen

private boolean canReopen(
        CarSettlement s
) {

    return ApprovalStatus
        .REJECTED
        .getCode()
        .equals(
            s.getApprovalStatus()
        )
        &&
        PaymentStatus
            .UNPAID
            .getCode()
            .equals(
                s.getPaymentStatus()
            );
}

一百二十四、是否还要检查流程结束

列表每一行都查 Runtime:

会增加查询

一百二十五、怎么办

因为当前 BPMN:

拒绝直接 EndEvent

所以:

REJECTED

理论上流程已经结束。


一百二十六、真正执行 reopen 时

再:

严格检查 Runtime

一百二十七、这是典型做法

列表:

轻量判断

写操作:

严格再次校验

一百二十八、为什么详情按钮永远可以显示

只要:

是自己的申请

就应该:

可查看

一百二十九、流程图按钮

只有:

processInstanceId != null

才显示。


一百三十、无需审批怎么办

如果:

approvalStatus = NOT_REQUIRED

可能:

没有 processInstanceId

这很正常。


一百三十一、前端不要显示“流程异常”

应该显示:

无需审批

一百三十二、流程进度按钮逻辑

processInstanceId 有值
→ 查看流程

无值 + NOT_REQUIRED
→ 无需审批

无值 + PROCESSING
→ 异常,需要排查

一百三十三、这是非常好的诊断规则


一百三十四、申请详情中的审批时间线

如果:

无需审批

时间线可以:

提交
↓
系统判定无需审批

一百三十五、是否需要 ApprovalRecord 写“无需审批”

可以。

但第一版:

不必

一百三十六、前端直接根据状态展示

无需审批

即可。


一百三十七、如果需要更完整审计

可以写:

SYSTEM_AUTO_APPROVE

当前:

不做

一百三十八、申请状态和支付状态的闭环

审批通过:

申请人看到:
审批通过,待支付

一百三十九、财务收款后

申请人看到:
已支付 / 已完成

一百四十、所以“我的申请”不仅是工作流页面

它实际上:

串起审批 + 支付最终状态

一百四十一、是否允许申请人在审批通过后修改

绝对:

不允许

一百四十二、是否允许审批拒绝后直接改

需要:

先 reopen

一百四十三、为什么需要明确业务动作

不要:

只靠 UPDATE status

一百四十四、正确

reopenApplication()

表达:

业务意图

一百四十五、Service 方法命名

推荐:

selectMyApplications

getMyApplicationDetail

reopenApplication

buildApplicationTimeline

canReopen

一百四十六、不要命名

updateStatus

因为:

看不出业务含义

一百四十七、reopen SQL

推荐带旧状态条件。

UPDATE car_settlement
SET
    status = #{draftStatus},
    approval_status = #{pendingStatus},
    process_instance_id = NULL,
    update_time = NOW()
WHERE
    settlement_id = #{settlementId}
    AND approval_status = #{rejectedStatus}
    AND payment_status = #{unpaidStatus};

一百四十八、为什么带旧状态

防止:

并发状态变化

一百四十九、Service 检查 rows

if (
    rows == 0
) {
    throw new ServiceException(
        "申请状态已变化,请刷新后重试"
    );
}

一百五十、为什么 processInstanceId 清空

重新编辑后:

当前没有运行审批流程

所以:

process_instance_id = NULL

更符合当前状态。


一百五十一、旧流程 ID 去哪了

已经保存在:

car_approval_record.process_instance_id

一百五十二、重新提交

新的 Activiti:

生成新 processInstanceId

一百五十三、那“我的申请”如何看旧审批记录

时间线:

按 business_id 查询

所以:

旧审批记录仍存在

一百五十四、这是一个关键点

如果一张结算单重提:

timeline

会包含:

第一次经理拒绝

第二次重新提交

第二次经理通过

第二次财务通过

一百五十五、当前 ApprovalRecord 只有审批动作

如果要显示:

第二次提交

可以增加:

SUBMIT

记录。


一百五十六、推荐升级 action

SUBMIT

APPROVE

REJECT

REOPEN

一百五十七、为什么这样更完整

审批时间线:

真正成为业务全过程

一百五十八、是否必须

不是。

但为了“我的申请”更好看:

推荐

一百五十九、提交时写 ApprovalRecord 吗

可以新增:

activityId = submit

activityName = 提交审批

action = SUBMIT

一百六十、这不是 Activiti Task

所以:

taskId = NULL

一百六十一、如果 task_id 有 UNIQUE

MySQL UNIQUE 对多个 NULL:

通常允许

所以:

SUBMIT / REOPEN

可以 task_id = NULL。


一百六十二、审批 Task

仍然:

task_id 非空且唯一

一百六十三、Timeline 更完整

SUBMIT
↓
APPROVE
↓
APPROVE

或:

SUBMIT
↓
REJECT
↓
REOPEN
↓
SUBMIT
↓
APPROVE
↓
APPROVE

一百六十四、action 显示文案

SUBMIT → 提交审批

REOPEN → 重新编辑

APPROVE → 审批通过

REJECT → 审批拒绝

一百六十五、为什么业务记录比纯 History 更适合这种 Timeline

因为:

REOPEN

不属于 Activiti Task。

业务表:

可以统一表达

一百六十六、这就是领域事件/业务事件思想

简单理解:

不是只有流程引擎发生的事才值得记录

业务动作:

也要有自己的历史

一百六十七、当前课程是否要做真正 Event Sourcing

不需要。


一百六十八、ApprovalRecord 就够了

作为:

轻量业务操作轨迹

一百六十九、重新编辑后前端跳去哪

可以:

跳到结算编辑页

一百七十、例如

router.push({
    path:
        '/car/settlement/edit',
    query: {
        id:
            row.settlementId
    }
})

一百七十一、如果当前是 Dialog 编辑

可以直接:

打开编辑 Dialog

一百七十二、课程推荐

沿用现有结算页:

打开编辑

减少新页面。


一百七十三、重新提交前再次验证什么

一定:

金额一致

明细完整

结算 DRAFT

没有正在运行的旧流程

一百七十四、为什么没有运行中的旧流程很重要

避免:

同一结算
同时两条审批流

一百七十五、submitSettlement 里增加检查

如果:

processInstanceId != null

可以先查 Runtime。


一百七十六、如果查到运行流程

拒绝:

当前申请已存在审批流程

一百七十七、reopen 后我们已经清空 processInstanceId

所以正常:

不会有

一百七十八、但数据库异常时仍有防护


一百七十九、我的申请排序

建议:

submit_time DESC

一百八十、草稿要不要显示在“我的申请”

有两种理解。


一百八十一、严格工作流视角

“我的申请”:

只显示已经提交过的

那么:

submit_time IS NOT NULL

一百八十二、草稿去哪

在:

结算管理

一百八十三、课程推荐

我的申请:

只显示已经正式提交过审批的业务

一百八十四、为什么

页面职责:

更清楚

一百八十五、被拒绝 reopen 后 submit_time 怎么办

不要清空。

因为它:

表示最近一次提交时间?

一百八十六、这里要定义语义

如果只有一个:

submit_time

推荐定义:

最近一次提交时间

一百八十七、reopen 时

保留。

重新提交时:

更新为新的提交时间

一百八十八、那第一次提交时间丢了

业务历史:

ApprovalRecord SUBMIT

可以保留。


一百八十九、如果你想同时保存首次提交时间

可以:

first_submit_time
last_submit_time

当前:

不需要

一百九十、简单优先

submit_time = 最近一次提交时间

一百九十一、我的申请列表只筛 submit_time 非空

AND s.submit_time IS NOT NULL

一百九十二、reopen 后仍属于“我的申请”吗

应该:

属于

因为:

它已经发起过

一百九十三、即使当前重新变成 DRAFT

也可以继续显示:

待重新提交

一百九十四、这就需要 displayStatus

例如:

status = DRAFT
approvalStatus = PENDING
submitTime != null

显示:

待重新提交

一百九十五、首次新建草稿

submitTime = null

不出现在:

我的申请

一百九十六、这一区分很合理

从未提交的草稿
→ 结算管理

已经发起过的申请
→ 我的申请

一百九十七、我的申请接口 SQL 增加

AND s.submit_time IS NOT NULL

一百九十八、页面综合状态示例

DRAFT + submitTime != null
→ 待重新提交

PROCESSING
→ 审批中

REJECTED
→ 审批拒绝

APPROVED + UNPAID
→ 审批通过,待支付

PAID
→ 已完成

NOT_REQUIRED + UNPAID
→ 无需审批,待支付

一百九十九、为什么这比只显示三个 Tag 更友好

普通用户:

不想自己组合状态

二百、但三个 Tag 仍可以保留

管理员:

看精确状态

普通申请人:

看综合状态

二百零一、我的申请详情中要不要显示流程定义版本

普通用户:

不需要

二百零二、管理员调试页才需要

processDefinitionId

二百零三、我的申请页面不要暴露技术字段

例如:

taskId
processDefinitionId
deploymentId

二百零四、只展示业务信息


二百零五、为什么

这是:

面向业务用户

不是:

开发调试页面

二百零六、流程图技术字段放开发日志即可


二百零七、审批拒绝原因

申请人应该:

醒目显示

二百零八、如何获取最后拒绝原因

car_approval_record:

action = REJECT

按时间:

倒序第一条

二百零九、SQL

SELECT
    comment
FROM car_approval_record
WHERE
    business_type = 'SETTLEMENT'
    AND business_id = #{businessId}
    AND action = 'REJECT'
ORDER BY
    create_time DESC,
    approval_record_id DESC
LIMIT 1;

二百一十、列表要不要直接显示 rejectReason

可以。

例如:

鼠标悬停

二百一十一、课程推荐

列表:

只显示“审批拒绝”

详情:

显示完整原因

二百一十二、为什么

表格不要:

塞太多长文本

二百一十三、审批时间线已经能完整展示


二百一十四、重新提交次数

要不要做字段:

submit_count

当前:

不必

二百一十五、可以从 ApprovalRecord SUBMIT 数量统计

COUNT(*)
WHERE action = 'SUBMIT'

二百一十六、为什么不急着冗余

读取频率:

不高

二百一十七、我的申请统计卡片要不要做

例如:

审批中 5
已通过 10
已拒绝 2

不是课程核心。

当前:

不做

二百一十八、为什么

先完成:

功能闭环

二百一十九、审批通知要不要做

例如:

审批通过通知申请人

当前:

不做

二百二十、后面企业项目可以用

WebSocket
站内消息
短信

二百二十一、当前课程避免功能膨胀


二百二十二、我的申请与“我的待办”是否会同时出现同一业务

可能。

例如申请人:

同时也是经理

二百二十三、那他既是申请人又是审批人怎么办

业务规则可以:

禁止自审

二百二十四、自审是一个真实问题

如果:

申请人 roleKey = store_manager

BPMN 经理节点:

candidateGroup = store_manager

他可能:

审批自己的申请

二百二十五、是否允许

企业财务审批:

通常不建议

二百二十六、课程推荐

增加:

禁止申请人审批自己的结算

二百二十七、在哪里校验

claim / approve:

都可以

二百二十八、最好 claim 前阻止

if (
    SecurityUtils
        .getUserId()
        .equals(
            settlement
                .getApplicantUserId()
        )
) {
    throw new ServiceException(
        "申请人不能审批自己的申请"
    );
}

二百二十九、为什么 claim 就阻止

避免:

任务先被错误领取

二百三十、管理员是否允许自审

默认:

也不允许

除非:

业务明确允许

二百三十一、这是职责分离

英文常叫:

Segregation of Duties

简单理解:

申请和审批最好不是同一个人

二百三十二、这是很好的面试点


二百三十三、另一个问题:申请人是否能撤回

现实中常见。


二百三十四、当前课程要不要做

不建议现在增加。


二百三十五、为什么

撤回涉及:

Runtime ProcessInstance 删除/终止
任务清理
业务状态恢复
审批记录

复杂度上升。


二百三十六、当前课程只实现

被拒绝后重新编辑

足够。


二百三十七、如果未来做撤回

应该定义:

未被审批前可撤回

而不是:

随时撤回

二百三十八、目前不实现


二百三十九、数据库索引

car_settlement 推荐:

KEY idx_settlement_applicant (
    applicant_user_id,
    submit_time
)

二百四十、为什么

“我的申请”查询:

WHERE applicant_user_id = ?
ORDER BY submit_time DESC

很匹配。


二百四十一、ApprovalRecord 索引

KEY idx_approval_business (
    business_type,
    business_id,
    create_time
)

二百四十二、为什么

审批时间线:

经常按 business 查询

二百四十三、完整索引建议

ALTER TABLE car_settlement
ADD KEY idx_settlement_applicant (
    applicant_user_id,
    submit_time
);

ALTER TABLE car_approval_record
ADD KEY idx_approval_business (
    business_type,
    business_id,
    create_time
);

二百四十四、不要为了所有字段加索引

只给:

真实查询条件

二百四十五、我的申请导出要不要做

当前:

不需要

二百四十六、为什么

个人申请页面:

主要查询和跟踪

财务导出:

已经在结算管理

二百四十七、避免重复功能


二百四十八、前端详情按钮

<el-button
    v-hasPermi="[
        'car:workflow:application:query'
    ]"
    link
    type="primary"
    @click="
        handleDetail(row)
    "
>
    详情
</el-button>

二百四十九、查看流程

<el-button
    v-if="
        row.processInstanceId
    "
    link
    @click="
        handleProcess(row)
    "
>
    查看流程
</el-button>

二百五十、重新编辑

<el-button
    v-if="
        row.canReopen
    "
    v-hasPermi="[
        'car:workflow:application:reopen'
    ]"
    link
    type="warning"
    @click="
        handleReopen(row)
    "
>
    重新编辑
</el-button>

二百五十一、handleReopen

function handleReopen(
    row
) {

    proxy.$modal
        .confirm(
            '确认将该申请恢复为草稿并重新编辑吗?'
        )
        .then(
            () =>
                reopenApplication(
                    row.settlementId
                )
        )
        .then(
            () => {

                proxy.$modal
                    .msgSuccess(
                        '已恢复为草稿'
                    )

                getList()
            }
        )
}

二百五十二、重新编辑后 UI 怎么引导

成功后:

可以提示:
请前往结算管理修改后重新提交

二百五十三、也可以直接打开编辑页

课程:

按现有页面结构决定

二百五十四、后端 reopen Controller

@PreAuthorize(
    "@ss.hasPermi('car:workflow:application:reopen')"
)
@Log(
    title = "我的申请",
    businessType =
        BusinessType.UPDATE
)
@RepeatSubmit(
    interval = 3000
)
@PostMapping(
    "/{settlementId}/reopen"
)
public AjaxResult reopen(
        @PathVariable
        Long settlementId
) {

    applicationService
        .reopenApplication(
            settlementId
        );

    return success();
}

二百五十五、为什么 reopen 也 RepeatSubmit

防:

快速连续点击

二百五十六、数据库条件更新仍是最终保护


二百五十七、reopen 是否写业务记录

推荐:

action = REOPEN

二百五十八、record

activityId = reopen

activityName = 重新编辑

operatorUserId = 当前申请人

action = REOPEN

comment = NULL

二百五十九、提交时也写 SUBMIT

这样 Timeline:

完整

二百六十、Application Timeline 查询

继续:

按 businessId

而不是:

只查当前 processInstanceId

二百六十一、为什么

重新提交后:

会有多个 processInstanceId

但业务还是:

同一个 settlementId

二百六十二、业务时间线应该跨流程实例

所以:

business_id

是更好的聚合键。


二百六十三、流程图则看当前/某次流程实例

两者职责不同。


二百六十四、这是本章非常重要的区别

业务历史
按 businessId

流程图
按 processInstanceId

二百六十五、如果要查看第一次被拒绝的旧流程图

当前页面:

默认只看最近一次

二百六十六、以后可以在 Timeline 点某次流程实例

不是当前课程核心。


二百六十七、我的申请详情 currentNodeName

怎么获得?

运行中:

TaskService

查询当前 ProcessInstance Task。


二百六十八、例如

List<Task> tasks =
        taskService
            .createTaskQuery()
            .processInstanceId(
                settlement
                    .getProcessInstanceId()
            )
            .active()
            .list();

二百六十九、单节点流程

可以取:

第一个 task.name

二百七十、并行流程怎么办

显示:

多个当前节点

所以更通用:

currentNodeNames List<String>

二百七十一、课程当前是否需要

可以简化:

String currentNodeName

但前面我们已经强调 List 思维。


二百七十二、更好

private List<String>
    currentNodeNames;

二百七十三、申请人看到

当前节点:
经理审批

或:

财务审批

二百七十四、流程结束

currentNodeNames = []

二百七十五、页面显示

审批已结束

二百七十六、如果 processInstanceId null + NOT_REQUIRED

显示:

无需审批

二百七十七、如果 processInstanceId null + DRAFT

显示:

待提交

二百七十八、这就是完整状态解释


二百七十九、我的申请是否需要 TaskService 权限

不需要:

审批任务权限

后端可以通过业务服务内部:

查询当前流程节点

二百八十、用户不需要直接操作 Task


二百八十一、申请人只能看不能审批


二百八十二、自审校验最好在哪些地方

至少:

claimTask

二百八十三、approveTask 也再检查

因为:

不能只依赖 claim

二百八十四、为什么双重检查

如果历史数据里:

已经错误 claim

approve 仍要:

阻止

二百八十五、代码

private void validateNoSelfApproval(
        CarSettlement settlement
) {

    if (
        SecurityUtils
            .getUserId()
            .equals(
                settlement
                    .getApplicantUserId()
            )
    ) {
        throw new ServiceException(
            "申请人不能审批自己的申请"
        );
    }
}

二百八十六、这也是业务安全,不只是技术安全


二百八十七、我的申请测试用例 1

用户 A:

提交结算 100

A 的我的申请:

能看到

二百八十八、测试 2

用户 B:

不能看到 A 的申请

二百八十九、测试 3

A 手工改 URL:

访问 B 的 settlementId

预期:

拒绝

二百九十、测试 4

新草稿:

未提交

不出现在:

我的申请

二百九十一、测试 5

提交后:

submitTime 不为空
applicantUserId = 当前用户

二百九十二、测试 6

审批中:

显示当前节点

二百九十三、测试 7

经理通过:

当前节点变财务审批

二百九十四、测试 8

审批通过:

当前节点空
状态显示审批通过

二百九十五、测试 9

审批拒绝:

显示拒绝原因

二百九十六、测试 10

拒绝申请:

canReopen = true

二百九十七、测试 11

审批通过:

canReopen = false

二百九十八、测试 12

已支付:

不能 reopen

二百九十九、测试 13

拒绝后 reopen:

恢复 DRAFT

三百、测试 14

reopen:

旧 Runtime 流程仍存在

预期:

拒绝

三百零一、测试 15

reopen 后修改优惠:

成功

三百零二、测试 16

重新提交:

产生新的 processInstanceId

三百零三、测试 17

Approval Timeline:

保留第一次拒绝记录

三百零四、测试 18

Timeline:

出现 REOPEN + 第二次 SUBMIT

三百零五、测试 19

申请人自己同时是 store_manager:

不能 claim 自己的申请

三百零六、测试 20

申请人直接调 approve 接口:

即使有角色
也因自审规则拒绝

三百零七、常见 Bug 1:我的申请能查别人

检查:

applicant_user_id 是否由后端强制

三百零八、Bug 2:create_by 当 applicant

会导致:

创建人和提交人不一致

时出错。


三百零九、Bug 3:草稿全出现在我的申请

检查:

submit_time IS NOT NULL

三百一十、Bug 4:审批拒绝后不能看原因

检查:

car_approval_record
action = REJECT

三百一十一、Bug 5:reopen 后旧流程还在运行

必须:

ensureProcessEnded

三百一十二、Bug 6:重新提交覆盖旧审批历史

如果 Timeline:

只按 processInstanceId 查询

就会丢旧流程记录。


三百一十三、正确

业务 Timeline:

按 businessId 查询

三百一十四、Bug 7:重新提交后流程图还是旧流程

检查:

car_settlement.process_instance_id

是否更新为:

新实例 ID

三百一十五、Bug 8:申请人看不了自己的流程图

因为:

Diagram API 只按 DataScope

没有考虑:

applicant ownership

三百一十六、Bug 9:申请人能看所有流程图

如果:

为了修 Bug 8
直接取消权限

就是严重错误。


三百一十七、正确

本人
OR
DataScope

三百一十八、Bug 10:审批人审批自己的申请

说明:

缺 self-approval check

三百一十九、Bug 11:reopen 后仍显示审批拒绝

检查:

approval_status

是否更新。


三百二十、Bug 12:重新提交后 submitTime 还是旧时间

提交时:

应该更新为最新时间

三百二十一、Bug 13:我的申请列表 PageHelper 无效

确认:

查询是否走 MyBatis Mapper

三百二十二、这里和 Activiti TaskQuery 不一样


三百二十三、Bug 14:已拒绝申请还能支付

支付 Service:

必须检查 approvalStatus

三百二十四、Bug 15:无审批流程却显示“流程加载失败”

如果:

NOT_REQUIRED

应该:

显示无需审批

三百二十五、数据库检查:申请人为空

SELECT *
FROM car_settlement
WHERE
    submit_time IS NOT NULL
    AND applicant_user_id IS NULL;

正常新数据:

应为 0 行

三百二十六、检查流程中状态

SELECT *
FROM car_settlement
WHERE
    approval_status = '2'
    AND process_instance_id IS NULL;

如果:

2 = PROCESSING

正常:

应为 0 行

三百二十七、检查已拒绝但有支付

SELECT *
FROM car_settlement
WHERE
    approval_status = '4'
    AND paid_amount > 0;

正常:

应为 0 行

三百二十八、检查申请索引

SHOW INDEX
FROM car_settlement;

确认:

idx_settlement_applicant

三百二十九、Git 提交建议

申请人字段:

git commit -m "feat: track settlement applicant"

三百三十、我的申请列表:

git commit -m "feat: add my workflow applications"

三百三十一、申请详情:

git commit -m "feat: add application detail and progress"

三百三十二、拒绝重开:

git commit -m "feat: support rejected application reopen"

三百三十三、业务时间线:

git commit -m "feat: extend approval timeline with submit and reopen"

三百三十四、自审限制:

git commit -m "feat: prevent self approval"

三百三十五、Cursor 提示词:我的申请

当前项目是 RuoYi-Vue SpringBoot3 + 淘车湾 + Activiti。

请实现“我的申请”。

要求:
1. car_settlement 增加 applicant_user_id、submit_time
2. 提交结算时 applicantUserId 从 SecurityUtils 获取
3. 前端不能传 applicantUserId
4. 我的申请只查询 applicant_user_id = 当前 userId
5. 从未提交的草稿不显示
6. 返回 settlementNo、workOrderNo、客户、车牌、金额、审批状态、支付状态、processInstanceId、submitTime
7. MyBatis 查询使用 PageHelper
8. 点击详情再次校验申请人 Ownership
9. 不允许通过前端 userId 查询其他人的申请

三百三十六、Cursor 提示词:申请详情

请实现我的申请详情。

返回:
1. SettlementDetailVO
2. ApprovalTimeline
3. processInstanceId
4. currentNodeNames
5. canReopen

要求:
1. 只有 applicant_user_id = 当前用户才能按“我的申请”身份查看
2. 审批记录按 businessType=SETTLEMENT + businessId 查询
3. 当前运行节点通过 TaskService 查询
4. 流程已经结束时 currentNodeNames 为空
5. 无需审批时 processInstanceId 可以为空
6. 不向普通用户暴露 deploymentId 等技术字段

三百三十七、Cursor 提示词:拒绝后重新编辑

请实现 rejected application reopen。

要求:
1. 只有申请人本人
2. approvalStatus 必须 REJECTED
3. paymentStatus 必须 UNPAID
4. 旧 processInstance 必须已经结束
5. 恢复 SettlementStatus.DRAFT
6. approvalStatus 恢复待提交/待审批状态
7. processInstanceId 清空
8. 使用旧状态条件 UPDATE 防并发
9. 写一条业务 ApprovalRecord,action=REOPEN
10. reopen 后允许用户修改优惠,再复用原 submitSettlement 重新提交

三百三十八、Cursor 提示词:业务时间线升级

请升级 car_approval_record 业务时间线。

支持 action:
SUBMIT
REOPEN
APPROVE
REJECT

要求:
1. 审批 Task 的 taskId 仍保持唯一
2. SUBMIT/REOPEN taskId 可以为空
3. Timeline 按 businessId 查询,而不是只按当前 processInstanceId
4. 重新提交后仍能看到第一次拒绝记录
5. action 转换为中文业务文案
6. 申请人页面不直接展示网关等引擎内部节点

三百三十九、Cursor 提示词:流程图授权升级

请修改流程图查看权限。

当前流程图通过 processInstanceId → businessKey → settlementId。

授权规则:
1. 如果 settlement.applicantUserId == 当前用户,允许查看自己的申请流程
2. 否则继续按现有 Settlement DataScope / 业务权限检查
3. 不能为了让申请人查看就取消权限
4. 不允许任意登录用户通过 processInstanceId 查看他人流程

三百四十、Cursor 提示词:禁止自审

请增加“申请人不能审批自己的申请”规则。

要求:
1. claimTask 前检查
2. approveTask 再检查一次
3. 根据 Task → ProcessInstance → businessKey 找 settlement
4. 比较 settlement.applicantUserId 与 SecurityUtils.getUserId()
5. 相同则拒绝
6. 管理员默认也不能绕过,除非后续业务明确增加特殊代办能力

三百四十一、IDEA 调试重点

断点:

submitSettlement

selectMyApplications

requireMyApplication

getMyApplicationDetail

reopenApplication

ensureProcessEnded

validateNoSelfApproval

buildApplicationTimeline

三百四十二、前端调试重点

Network

application/list

application/{id}

diagram-data

reopen

三百四十三、第一次调试

创建新草稿:

我的申请不显示

三百四十四、提交后

检查:

applicant_user_id
submit_time
process_instance_id

三百四十五、我的申请

应该:

出现该结算

三百四十六、经理拒绝

申请人刷新:

显示审批拒绝

三百四十七、详情

Timeline:

显示拒绝原因

三百四十八、reopen

检查:

status = DRAFT

approvalStatus 恢复

processInstanceId = NULL

三百四十九、修改优惠

成功。


三百五十、重新提交

检查:

新 processInstanceId

三百五十一、Timeline

旧拒绝记录:

仍然存在

三百五十二、面试题 1:我的申请、我的待办、我的已办有什么区别

答:

我的申请是发起人视角,
查询自己提交的业务审批。

我的待办是审批人视角,
查询当前还需要自己处理或有资格领取的任务。

我的已办也是审批人视角,
查询自己过去已经完成的历史任务。

三者的用户目标和数据来源都不同。

三百五十三、面试题 2:为什么我的申请推荐查业务表而不是只查 Activiti

答:

申请页面不仅需要流程状态,
还需要结算编号、工单号、客户、车辆、金额、
支付状态和业务状态。

这些真实业务数据都在 car_settlement 等业务表中。

因此我的申请更适合以业务表为主,
流程引擎用于补充当前节点和审批进度。

三百五十四、面试题 3:为什么要 applicant_user_id,不能只用 create_by

答:

创建草稿的人不一定是最后正式提交审批的人。

applicant_user_id 表示真正发起本次审批的用户,
语义比 create_by 更准确。

而且它是稳定的用户 ID,
便于查询“我的申请”和实现禁止自审。

三百五十五、面试题 4:为什么 applicantUserId 不能由前端传

答:

申请人属于登录身份信息,
客户端请求可以被篡改。

如果前端可传 applicantUserId,
用户可能伪造别人 ID,
造成申请归属错误或越权查询。

因此必须由 SecurityUtils 获取当前用户。

三百五十六、面试题 5:为什么被拒绝后不直接自动改成 DRAFT

答:

如果审批拒绝后立刻变回草稿,
用户只看到草稿状态,
很难知道它曾经被拒绝。

保留 REJECTED 可以明确展示审批结果和拒绝原因,
再通过“重新编辑”这一显式业务动作恢复草稿,
流程更清晰,也更容易审计。

三百五十七、面试题 6:为什么 reopen 前要检查旧流程已经结束

答:

如果旧 Activiti ProcessInstance 还在运行,
这时把业务恢复成草稿并重新提交,
可能出现同一张结算同时存在两条审批流程。

因此 reopen 必须确认旧运行流程已经结束。

三百五十八、面试题 7:为什么业务时间线按 businessId 查

答:

一张结算被拒绝后可能重新提交,
这会产生新的 processInstanceId。

如果时间线只查当前 processInstanceId,
第一次审批历史就看不到了。

按 settlementId 等 businessId 聚合,
可以完整展示多次提交、拒绝、重开和再次审批全过程。

三百五十九、面试题 8:为什么流程图仍按 processInstanceId

答:

流程图表示某一次具体流程实例的执行路径。

同一业务可能多次提交,
每次都有不同的 ProcessInstance。

因此业务历史按 businessId 聚合,
流程图则按某个确定的 processInstanceId 展示,
两者职责不同。

三百六十、面试题 9:为什么要禁止申请人审批自己的申请

答:

申请和审批属于不同职责。

如果申请人可以审批自己提交的结算,
审批流程就失去独立审核意义。

因此在 claim 和 approve 时都应检查 applicantUserId,
避免自审。

三百六十一、面试题 10:为什么“我的申请”可以不用 DataScope

答:

我的申请列表本身已经强制使用
applicant_user_id = 当前登录用户,
这是更严格的本人 Ownership 限制。

因此列表不一定需要再使用部门 DataScope。

但其他角色通过业务管理页面查看结算时,
仍然应该使用 DataScope。

三百六十二、面试题 11:为什么流程图权限要支持 Applicant Ownership

答:

申请人即使没有审批角色或某些部门数据权限,
也应该能查看自己发起申请的审批进度。

因此流程图授权可以采用:
申请人本人 OR 合法业务 DataScope。

但不能简单取消流程图权限,
否则会造成任意流程实例越权查看。

三百六十三、面试题 12:为什么综合状态只用于展示

答:

审批状态、支付状态和结算状态是三个独立业务维度,
数据库应分别保存。

页面为了让普通用户更容易理解,
可以组合成“审批中”“审批通过待支付”“已完成”等综合文案。

这个综合状态属于展示逻辑,
不应该再冗余存进数据库。

三百六十四、知识树

My Application
│
├─ Applicant
│  ├─ applicantUserId
│  ├─ submitTime
│  └─ Ownership
│
├─ List
│  ├─ MyBatis
│  ├─ PageHelper
│  ├─ Settlement
│  └─ DisplayStatus
│
├─ Detail
│  ├─ SettlementDetailVO
│  ├─ ApprovalTimeline
│  ├─ CurrentNode
│  └─ ProcessDiagram
│
├─ Reject
│  ├─ REJECTED
│  ├─ RejectReason
│  └─ canReopen
│
├─ Reopen
│  ├─ Check Ownership
│  ├─ Check Payment
│  ├─ Check Process Ended
│  ├─ DRAFT
│  └─ processInstanceId = null
│
├─ Resubmit
│  ├─ Edit Discount
│  ├─ Validate Amount
│  ├─ Reuse submitSettlement
│  └─ New ProcessInstance
│
├─ Timeline
│  ├─ SUBMIT
│  ├─ REOPEN
│  ├─ APPROVE
│  └─ REJECT
│
└─ Security
   ├─ No Frontend ApplicantId
   ├─ No Self Approval
   ├─ Applicant Ownership
   └─ OR DataScope for Process View

三百六十五、完整申请人闭环

Create Settlement Draft
↓
Edit
↓
Submit
↓
applicantUserId = currentUser
↓
My Application
↓
Approval Processing
↓
Manager / Finance
├─ Approved
│  ↓
│  Applicant sees Approved
│  ↓
│  Payment
│  ↓
│  Completed
│
└─ Rejected
   ↓
   Applicant sees reason
   ↓
   Reopen
   ↓
   Edit discount
   ↓
   Submit again
   ↓
   New ProcessInstance

三百六十六、三类页面完整闭环

申请人
↓
我的申请
↓
提交审批

审批人
↓
我的待办
↓
claim
↓
approve/reject
↓
我的已办

申请人
↓
我的申请
↓
查看结果 / 流程 / 审批意见

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

你应该能够独立完成:

1. applicant_user_id

2. submit_time

3. 我的申请列表

4. 当前用户 Ownership 查询

5. MyApplicationVO

6. MyApplicationDetailVO

7. 结算详情复用

8. 审批 Timeline 复用

9. 流程图复用

10. 当前审批节点

11. 综合展示状态

12. 拒绝原因展示

13. canReopen

14. reopenApplication

15. 检查旧流程已结束

16. 被拒绝后恢复草稿

17. 修改优惠

18. 复用 submitSettlement 重新提交

19. 新 processInstanceId

20. 保留旧审批历史

21. ApprovalRecord SUBMIT

22. ApprovalRecord REOPEN

23. 业务 Timeline 按 businessId 聚合

24. 流程图按 processInstanceId 展示

25. 流程图 Applicant Ownership 授权

26. 禁止申请人自审

27. PageHelper 与 Activiti Query 分页区别

28. Vue 我的申请页面

29. 申请详情 Drawer

30. 审批记录 Tab

31. 流程进度 Tab

32. 权限码设计

33. 索引设计

34. 状态与并发校验

35. 完整工作流闭环

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

1. 我的申请是发起人视角

2. 我的待办和已办是审批人视角

3. applicantUserId 必须来自当前登录用户

4. createBy 不能完全代替 applicantUserId

5. 我的申请列表应该按本人 Ownership 查询

6. 从未提交的草稿不属于“我的申请”

7. 被拒绝后先保持 REJECTED,方便展示结果

8. 重新编辑必须是显式业务动作

9. reopen 前必须确认旧流程已结束

10. reopen 后可恢复 DRAFT,再修改优惠

11. 重新提交复用原 submitSettlement

12. 每次重新提交产生新的 ProcessInstance

13. 业务 Timeline 按 businessId 聚合

14. 流程图按具体 processInstanceId 展示

15. 申请人可以看自己流程,但不能看别人流程

16. Applicant Ownership 和 DataScope 是两种不同授权方式

17. 禁止申请人审批自己的申请

18. 综合状态只用于展示,不要再存一份数据库字段

19. 我的申请 MyBatis 查询可以用 PageHelper

20. Activiti TaskQuery 分页不能机械套 PageHelper

三百六十九、下一篇

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

《淘车湾项目实战(十):项目总结、Bug 调试及项目优化》

下一章会把整个淘车湾项目做一次完整收口:

项目模块总览

数据库关系总览

RBAC

DataScope

预约

工单

服务项目

服务套餐

结算

结算明细

Activiti 审批

我的申请

我的待办

我的已办

流程图高亮

事务检查

金额一致性

状态机检查

越权检查

SQL 性能

N+1

索引

Redis 使用边界

重复提交

并发更新

异常处理

日志

代码分层

Cursor 全项目审查提示词

最终项目答辩怎么讲

简历项目怎么写