7天学会SpringBoot+Vue3企业级项目RuoyiOffice(三):后端篇——从业务建模到接口开发,做出一个完整模块
🌐 文档地址:https://ruoyioffice.com 👇 文章底部获取源码和演示地址 👇 💬 :17156169080(获取产品咨询)
这是《7天学会SpringBoot+Vue3企业级项目RuoyiOffice》系列第 3 篇。前两天看完了架构地图,也把开发环境跑通了。第三天开始写业务:不再讲抽象概念,而是挑一个边界清楚、带审批、有主数据和单据的真实模块,从业务建模一路做到接口可被前端调用。读完你能回答三个问题:一个后端业务模块应该先画什么、代码按什么层次放、一次提交请求到底碰了哪几层。

▲ 本篇核心视觉:左侧是一次「提交」请求穿过的六层,右侧是用车主数据和两张单据的表关系,底部是流程状态与还车状态两条状态线。后面的每一节都能在这张图里找到位置。
引言:为什么用「用车」当案例
写后端文章最容易犯的错,是拿一个只有增删改查的玩具表做例子,读者看完依然不知道真实业务怎么落。企业项目的后端难点在于:主数据和单据怎么分、状态不止一种怎么管、审批流程怎么接、不同租户的数据怎么隔开。OA 用车模块恰好把这几件事都带上了,而且规模可控。
| 案例模块 | OA 用车(车辆信息、用车申请单、还车申请单) | 有主数据、有两张审批单据、有互相联动的状态 |
| 讲解范围 | 用车申请单为主线,车辆与还车单为辅 | 一条主线讲透,比三条主线各讲一半更有用 |
| 代码来源 | 仓库真实源码,不虚构类名与字段 | 读者可以直接在 IDE 里对照 |
| 流程部分 | 只讲「提交并回写实例编号」 | 审批引擎本身放到第六篇,这里不抢戏 |
说明:本文代码节选自仓库 ruoyi-office/yudao-module-oa,为便于阅读省略了部分空行与附件处理。表结构取自开发库的实际建表语句,字段含义以枚举和代码为准。
一、先建模:三个业务对象和两条状态线
动手写代码之前,先回答「这个模块里有哪些东西、它们有哪些状态」。用车模块有三个业务对象:
| 车辆信息 | oa_car | 主数据,长期存在 | 公司有哪些车、是否可用 |
| 用车申请单 | oa_car_apply_bill | 业务单据,走审批 | 谁在什么时间要用哪辆车 |
| 还车申请单 | oa_car_return_bill | 业务单据,走审批 | 用完之后车怎么还回来 |
先看系统里的车辆台账页面,这是主数据在界面上的样子:

▲ 车辆信息管理:车辆是主数据,状态只有「空闲」「停用」这类简单取值,和单据的审批状态是两回事。
单据的状态则复杂得多。一张用车申请单同时存在两条互不相同的状态线:
| 流程状态 | process_status | 流程引擎事件回写 | -1 未提交、1 审批中、2 审批通过、3 审批不通过、4 已取消、5 已退回、10 已撤回 |
| 还车状态 | return_status | 用车与还车业务逻辑 | 0 未生效、1 待还车、2 还车中、3 已还车 |
这个区分是后面所有设计的出发点:流程状态属于引擎,还车状态属于业务。业务模块不能把自己的业务状态也塞给流程引擎去管,否则审批一撤回,业务就乱了。

▲ 用车申请单列表:同一个列表里并存审批中、已撤回、审批通过和审批不通过,这些就是 process_status 在界面上的投影。
二、建表:每个字段都要有归属
三张表都包含审计字段(creator、create_time、updater、update_time、deleted)和租户字段 tenant_id。下面是用车申请单的核心结构,节选自开发库建表语句:
CREATE TABLE `oa_car_apply_bill` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT 'ID',
`bill_code` varchar(32) NOT NULL COMMENT '单据编号',
`process_instance_id` varchar(64) DEFAULT NULL COMMENT '流程实例编号',
`process_status` tinyint DEFAULT NULL COMMENT '单据状态',
`car_id` bigint DEFAULT NULL COMMENT '车辆',
`go_time` datetime DEFAULT NULL COMMENT '出车时间',
`return_time` datetime DEFAULT NULL COMMENT '回车时间',
`go_area` varchar(150) DEFAULT NULL COMMENT '出车地点',
`return_area` varchar(150) DEFAULT NULL COMMENT '回车地点',
`cause` varchar(255) DEFAULT NULL COMMENT '用车事由',
`creator` varchar(64) DEFAULT NULL COMMENT '创建者',
`deleted` bit(1) NOT NULL DEFAULT b'0' COMMENT '是否删除',
`tenant_id` bigint NOT NULL DEFAULT '0' COMMENT '租户编号',
`dept_id` bigint DEFAULT NULL COMMENT '部门ID',
`company_id` bigint DEFAULT NULL COMMENT '公司ID',
`return_status` tinyint(1) NOT NULL DEFAULT '0' COMMENT '还车状态,0-未还车,1-还车中,2-已还车',
`car_no` varchar(32) DEFAULT NULL COMMENT '车牌号码',
PRIMARY KEY (`id`),
KEY `idx_oa_car_apply_bill_is_returned` (`return_status`),
KEY `idx_oa_car_apply_bill_company_returned` (`company_id`,`return_status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用车申请单';
几个值得拎出来的字段决策:
| bill_code | 业务可读的单据号,由 BillCodeUtils 生成 | 还车单用它而不是主键 id 来指向用车单 |
| process_instance_id | 单据与流程实例的绑定凭证 | 为空说明流程没提交成功,是排障第一线索 |
| process_status | 流程引擎回写的审批状态 | 前端不要自己改它 |
| return_status | 业务自己的还车进度 | 建表注释写的是「0 未还车、1 还车中、2 已还车」,与枚举不一致 |
| tenant_id | 多租户隔离 | 默认值 0,不要手工写 |
| dept_id / company_id | 部门与公司冗余 | 为列表按公司过滤、权限范围服务 |
特别注意 return_status:建表注释是旧的,代码里的枚举 CarReturnStatusEnum 是 0 未生效、1 待还车、2 还车中、3 已还车。遇到注释和枚举冲突,永远以枚举和字典为准,并顺手修掉注释,否则下一个读代码的人会被误导。
三、分层:每一层只做一件事
OA 模块拆成 yudao-module-oa-api 和 yudao-module-oa-server。对外的枚举和错误码在 api,业务实现在 server。用车申请单涉及的文件如下:
| 枚举与错误码 | oa-api/…/enums/ErrorCodeConstants、CarReturnStatusEnum、OaBillTypeEnum | 对外共享,错误码段 1_101_000_xxx |
| Controller | oa-server/…/controller/admin/car/CarApplyBillController | 路由、权限、参数校验、统一返回 |
| VO | oa-server/…/controller/admin/car/vo/ | 请求与响应对象,隔离 DO |
| Service | oa-server/…/service/car/CarApplyBillServiceImpl | 业务规则、事务与流程协作 |
| Mapper | oa-server/…/dal/mysql/car/CarApplyBillMapper | 查询条件拼装 |
| DO | oa-server/…/dal/dataobject/car/CarApplyBillDO | 与表一一对应 |
对象转换这一层,官方规范里写的是 MapStruct 的 convert 包,但用车模块实际用的是 BeanUtils.toBean,目录里并没有 convert。这是读源码时要接受的现实:规范是方向,具体模块以代码为准,新模块选哪种方式要和团队约定一致。
四、Controller:权限、校验和统一返回
Controller 只做四件事:声明路径、声明权限、触发参数校验、包装返回。用车申请单的保存与提交是这样写的:
@Tag(name = "OA协同办公 – 用车申请单")
@RestController
@RequestMapping("/oa/car-apply-bill")
@Validated
public class CarApplyBillController {
@Resource
private CarApplyBillService carApplyBillService;
@PostMapping("/save")
@Operation(summary = "保存用车申请单")
@PreAuthorize("@ss.hasPermission('oa:car-apply-bill:save')")
public CommonResult<Long> saveCarApplyBill(@Valid @RequestBody CarApplyBillSaveReqVO saveReqVO) {
return success(carApplyBillService.saveCarApplyBill(saveReqVO));
}
@PostMapping("/submit")
@Operation(summary = "提交用车申请单")
@PreAuthorize("@ss.hasPermission('oa:car-apply-bill:create')")
public CommonResult<Long> submitCarApplyBill(@Valid @RequestBody CarApplyBillSaveReqVO createReqVO) {
return success(carApplyBillService.submitCarApplyBill(createReqVO));
}
这里有三个读代码时容易忽略的点:
VO 层也要如实说一件事:CarApplyBillSaveReqVO 里没有声明 @NotNull 等校验注解,@Valid 触发后实际没有字段级约束。目前必填由前端表单的 rules: 'required' 把关,后端只在 Service 里校验时间。如果你的新模块对数据质量要求高,建议在 VO 上补齐必填约束,不要只依赖前端。
时间字段是另一处约定:goTime 与 returnTime 在 VO 里是 LocalDateTime 且没有 @JsonFormat,按项目规范会以毫秒时间戳序列化,前端日期选择器需要配 valueFormat: 'x'。这个细节会在第四篇和前端代码一一对上。
五、Service:业务规则都在这里
Service 是整个模块的重心。提交用车申请要依次完成:生成单号、校验时间冲突、保存、提交流程、回写实例编号。核心方法如下(省略附件处理):
@Override
public Long submitCarApplyBill(CarApplyBillSaveReqVO saveReqVO) {
// 如果单号为空,需要生成
if(StringUtils.isBlank(saveReqVO.getBillCode())){
saveReqVO.setBillCode(BillCodeUtils.generateBillCode(SystemEnum.OA, OaBillTypeEnum.OA_CAR_APPLY_BILL));
}
// 校验时间冲突
validateTimeConflict(saveReqVO);
// 保存或更新
CarApplyBillDO carApplyBill = BeanUtils.toBean(saveReqVO, CarApplyBillDO.class)
.setProcessStatus(BpmTaskStatusEnum.RUNNING.getStatus());
carApplyBillMapper.insertOrUpdate(carApplyBill);
// 智能提交 BPM 流程(如果流程实例不存在则创建,存在则审批发起人任务)
Map<String, Object> processInstanceVariables = BpmProcessVariableUtils.buildBillVariables(saveReqVO);
String processInstanceId = processInstanceApi.submitProcessInstance(Long.valueOf(saveReqVO.getCreator()),
new BpmProcessInstanceCreateReqDTO()
.setProcessDefinitionKey(OaBillTypeEnum.OA_CAR_APPLY_BILL.getProcessDefinitionKey())
.setVariables(processInstanceVariables)
.setStartUserSelectAssignees(saveReqVO.getStartUserSelectAssignees())
.setBusinessKey(String.valueOf(carApplyBill.getId()))
).getCheckedData();
// 将工作流的编号,更新到单据中
carApplyBillMapper.updateById(new CarApplyBillDO().setId(carApplyBill.getId()).setProcessInstanceId(processInstanceId));
return carApplyBill.getId();
}
读这段代码,抓住三个关键点:
- businessKey 是业务与流程的桥。提交流程时把单据 id 当作 businessKey 交给引擎,审批结束后引擎再带着这个值回来找单据,这就是第六篇「回写」的起点。
- processDefinitionKey 来自枚举 OaBillTypeEnum,用车申请单对应 oa_car_apply_bill。流程模型的标识必须和它一致,否则提交时找不到流程定义。
- 提交时先把状态置为「审批中」再入库,随后才拿到实例编号回写。这就是为什么排障时看到「审批中」但 process_instance_id 为空,意味着流程提交这一步没成功。
用车场景最有业务含量的规则是时间冲突校验:同一辆车在重叠时间段内,不能同时存在两张有效的申请单。
private void validateTimeConflict(CarApplyBillSaveReqVO saveReqVO) {
if (saveReqVO.getCarId() == null || saveReqVO.getGoTime() == null || saveReqVO.getReturnTime() == null) {
return; // 如果必要字段为空,跳过校验
}
// 校验出车时间不能晚于回车时间
if (saveReqVO.getGoTime().isAfter(saveReqVO.getReturnTime())) {
throw exception(CAR_TIME_CONFLICT);
}
// 查询同一车辆在相同时间段内的申请单
List<CarApplyBillDO> conflictBills = carApplyBillMapper.selectList(
new LambdaQueryWrapperX<CarApplyBillDO>()
.eq(CarApplyBillDO::getCarId, saveReqVO.getCarId())
.ne(saveReqVO.getId() != null, CarApplyBillDO::getId, saveReqVO.getId()) // 排除当前编辑的记录
.and(wrapper -> wrapper
// 场景1:存在审批中的申请单且时间重合
.and(subWrapper -> subWrapper
.eq(CarApplyBillDO::getProcessStatus, RUNNING.getStatus())
// ……时间重叠条件(三种区间重叠情形)
这里的设计思路值得借鉴:哪些单据会「占住」一辆车,由两个状态共同决定。场景一是流程状态为审批中的单据;场景二是审批已通过、且还车状态为「待还车」或「还车中」的单据。已还车、被拒绝、被撤回的单据不再占车。这正是两条状态线配合的价值:流程状态说明审批走到哪,还车状态说明车是否真被占着。
六、Mapper 与 DO:查询条件和数据边界
Mapper 继承 BaseMapperX,分页查询用 LambdaQueryWrapperX 的 xxxIfPresent 系列方法拼接条件,值为空时自动跳过,列表页的筛选就是这样落地的:
@Mapper
public interface CarApplyBillMapper extends BaseMapperX<CarApplyBillDO> {
default PageResult<CarApplyBillDO> selectPage(CarApplyBillPageReqVO reqVO) {
return selectPage(reqVO, new LambdaQueryWrapperX<CarApplyBillDO>()
.eqIfPresent(CarApplyBillDO::getId, reqVO.getId())
.likeIfPresent(CarApplyBillDO::getBillCode, reqVO.getBillCode())
.eqIfPresent(CarApplyBillDO::getProcessInstanceId, reqVO.getProcessInstanceId())
.eqIfPresent(CarApplyBillDO::getProcessStatus, reqVO.getProcessStatus())
.eqIfPresent(CarApplyBillDO::getCarNo, reqVO.getCarNo())
.betweenIfPresent(CarApplyBillDO::getGoTime, reqVO.getGoTime())
.betweenIfPresent(CarApplyBillDO::getReturnTime, reqVO.getReturnTime())
.eqIfPresent(CarApplyBillDO::getCreator, reqVO.getCreator())
.betweenIfPresent(CarApplyBillDO::getCreateTime, reqVO.getCreateTime())
.eqIfPresent(CarApplyBillDO::getDeptId, reqVO.getDeptId())
.eqIfPresent(CarApplyBillDO::getCompanyId, reqVO.getCompanyId())
.eqIfPresent(CarApplyBillDO::getReturnStatus, reqVO.getReturnStatus())
// ……其余字段条件同理
.orderByDesc(CarApplyBillDO::getId));
}
}
Mapper 里看不到任何 tenant_id 条件,是因为租户隔离不写在业务代码里:DO 继承 BaseDO,查询与写入时由 MyBatis-Plus 的租户拦截器按当前请求的租户自动拼接条件。第五篇会把这条拦截链完整讲一遍。
另一个业务层面的数据边界写在 Service 里:
// === CarApplyBillServiceImpl ===
@Override
public PageResult<CarApplyBillDO> getCarApplyBillPage(CarApplyBillPageReqVO pageReqVO) {
// 自动添加创建人过滤条件(当前登录用户)
Long currentUserId = SecurityFrameworkUtils.getLoginUserId();
if (currentUserId != null) {
pageReqVO.setCreator(String.valueOf(currentUserId));
}
return carApplyBillMapper.selectPage(pageReqVO);
}
// === ErrorCodeConstants(oa-api)===
ErrorCode CAR_NOT_EXISTS = new ErrorCode(1_101_000_000, "车辆信息不存在");
ErrorCode CAR_APPLY_BILL_NOT_EXISTS = new ErrorCode(1_101_000_001, "用车申请单不存在");
ErrorCode CAR_RETURN_BILL_NOT_EXISTS = new ErrorCode(1_101_000_003, "还车申请单不存在");
ErrorCode CAR_APPLY_BILL_ALREADY_RETURNED = new ErrorCode(1_101_000_004, "用车申请单已还车,不能重复还车");
ErrorCode CAR_TIME_CONFLICT = new ErrorCode(1_101_000_005, "车辆使用时间冲突,该时间段已有其他申请单");
注意分页方法强制把 creator 改写成当前登录用户。也就是说,前端传什么 creator 都没用,列表只会返回自己创建的单据。这就是「数据范围不能只靠前端」的最小例子:即使有人改了请求参数,后端也会把它纠正回来。错误码则统一放在 ErrorCodeConstants,按模块分段,业务代码里只写 throw exception(CAR_TIME_CONFLICT),文案和错误码不散落在各处。
七、事务与一致性:先看清再决定
读完提交方法,你应该问一个问题:保存单据、提交流程、回写实例编号,这三步在一个事务里吗?
按当前源码,CarApplyBillServiceImpl 与 CarReturnBillServiceImpl 没有声明 @Transactional,这三步各自独立提交。这意味着:如果流程提交抛异常,前面已经入库的单据会保留,状态是「审批中」而实例编号为空。
这既不是危言耸听,也不是要你照抄去加注解——是否应该放进一个事务、失败后回滚还是补偿,取决于流程引擎调用的失败语义。本文的建议是:新模块要显式设计这个边界,并在文档里写清楚,不要把「碰巧能跑」当成设计。已有数据排查时,用下面的查询找出这类单据:
SELECT id, bill_code, process_status
FROM oa_car_apply_bill
WHERE process_status = 1
AND (process_instance_id IS NULL OR process_instance_id = '');
八、闭环:一次提交从浏览器到数据库
把前面各层串起来,一次「提交用车申请」的完整链路如下:

▲ 时序图:权限与参数校验在 Controller,业务规则在 Service,租户条件在持久层自动拼接,流程提交通过 BpmProcessInstanceApi 完成,实例编号最后回写到单据。
| 1 | 浏览器调用 POST /admin-api/oa/car-apply-bill/submit | 带 Token 的请求 |
| 2 | Controller 校验 oa:car-apply-bill:create 与 @Valid | 不通过直接返回错误 |
| 3 | Service 生成单号、校验时间冲突 | 冲突抛 CAR_TIME_CONFLICT |
| 4 | Mapper 落库,状态置为审批中 | 得到单据 id |
| 5 | 调用 submitProcessInstance,businessKey 为单据 id | 得到流程实例编号 |
| 6 | 回写 process_instance_id | 单据与流程绑定完成 |
| 7 | 返回 CommonResult.success(id) | 前端据此刷新页面 |
自己验证时不必写任何新代码:在 PC 端新建一张用车申请并提交,打开浏览器 Network,找到 submit 请求,响应里应该是成功码和一个数字 id;再用上面的 SQL 查最新一条记录,看 process_status 与 process_instance_id 是否同时有值。本文没有替你执行这些步骤,请在自己的环境里核对。
提交之后的页面长什么样?下图是一张已通过审批的用车申请单详情:

▲ 用车申请单详情:顶部是单据号、申请人和状态,下方的页签把业务数据与审批过程放在同一个页面里,这一页的前端实现放到第四篇讲。
还车申请单则是另一张单据,通过 apply_bill 指向用车单的 bill_code,并联动修改用车单的 return_status:

▲ 还车申请单列表:每张还车单都关联一张用车单,还车审批通过后,用车单才会变成「已还车」。
九、故障与验收矩阵
模块做完后,用下表自检,也可以在联调出现问题时当排查顺序:
| 点提交提示无权限 | 权限标识 | 看响应码;核对菜单按钮权限标识是否为 oa:car-apply-bill:create 并已授权给角色 |
| 提示「车辆使用时间冲突」 | Service 业务规则 | 对照 CAR_TIME_CONFLICT;查同一车辆的审批中与待还车单据 |
| 列表只有自己的单据 | Service 数据边界 | 这是分页方法强制按创建人过滤的结果,属于设计行为 |
| 时间选择器显示 NaN | 前后端日期约定 | 看接口返回是否为毫秒数,前端是否配 valueFormat: 'x' |
| 状态是审批中但没有实例编号 | 事务边界 | 用第七节的 SQL 查,再看后端日志里流程提交的异常 |
| 审批通过后还车状态仍是 0 | 流程回写 | 看第六篇的事件监听链路是否执行了 updateProcessStatus |
| 重复还车被拒绝 | 业务规则 | 对照 CAR_APPLY_BILL_ALREADY_RETURNED |
十、第三天的学习结果
做完后端篇,建议逐项确认:
常见问题(FAQ)
新增一个业务模块,后端应该先写什么?
先写业务建模,再写代码:列出业务对象,区分主数据和单据,列出每个对象的状态线,再决定建表。建表之后再按 DO、VO、Mapper、Service、Controller 的顺序写,最后补错误码和单元测试。
为什么业务状态不直接用流程引擎的状态?
流程状态只表示审批走到哪一步,会被撤回、退回、取消等操作反复改变。用车单的「待还车、还车中、已还车」是业务自己的进度,必须独立存放,否则审批一动业务就乱。
Controller 方法没有 @PreAuthorize 会有风险吗?
要看接口是否返回敏感数据以及后端有没有数据范围兜底。本文案例中分页接口在 Service 里强制按创建人过滤,详情接口另有 get-for-bpm 校验流程参与者。新接口建议默认加权限,确认不需要时再显式取消。
BeanUtils.toBean 与 MapStruct 怎么选?
用车模块实际使用的是 BeanUtils.toBean,字段同名同类型时最省事;需要复杂映射、跨对象合并时再引入 MapStruct。关键是同一个模块内风格统一,并在评审时说清选择。
本文的代码我能直接复制到自己项目吗?
不建议整段复制。它们依赖 BaseMapperX、BillCodeUtils、BpmProcessInstanceApi 等平台能力。正确用法是对照仓库源码理解分层思路,在自己的模块里按同样的职责边界重写。
系列进度与下一篇预告
| (一) | 架构篇——一个底座,多端通达 | 已发布 |
| (二) | 启动篇——从源码到前后端联调,跑通开发环境 | 已发布 |
| (三) | 后端篇——从业务建模到接口开发,做出一个完整模块 | 本篇 |
| (四) | 前端篇——从菜单路由到表单列表,完成业务页面 | 下一篇 |
| (五) | 权限篇——把登录、角色、数据权限与租户隔离讲透 | 预告 |
| (六) | 流程篇——接入审批,让业务单据真正流转起来 | 预告 |
| (七) | 上线篇——从测试验收到部署上线,完成企业项目交付 | 预告 |
后端接口已经有了,但用户看不到页面就等于没有。下一篇进入前端篇:从菜单与路由出发,用 Vben 的 Page、useVbenVxeGrid 和 BasicForm 做出用车申请的列表页与表单页,并把今天提到的时间戳、权限标识和状态字典在界面上一一对上。想深入同一业务的设计细节,可延伸阅读 公务用车:申请占坑、还车释放、撞时段怎么拦。
如果这篇对你有用,点个「在看」或收藏。
🌐 演示地址:https://ruoyioffice.com/web 📦 GitHub 源码:https://github.com/yuqing2026/ruoyi-office 📦 Gitee 源码:https://gitee.com/yqzy1688/ruoyi-office 💬 微信:17156169080(获取产品咨询)

打开演示地址直接查看系统。







