7天学会SpringBoot+Vue3企业级项目RuoyiOffice(三):后端篇——从业务建模到接口开发,做出一个完整模块
🌐 文档地址:https://ruoyioffice.com
👇 文章底部获取源码和演示地址 👇
💬 :17156169080(获取产品咨询)
这是《7天学会SpringBoot+Vue3企业级项目RuoyiOffice》系列第 3 篇。前两天看完了架构地图,也把开发环境跑通了。第三天开始写业务:不再讲抽象概念,而是挑一个边界清楚、带审批、有主数据和单据的真实模块,从业务建模一路做到接口可被前端调用。读完你能回答三个问题:一个后端业务模块应该先画什么、代码按什么层次放、一次提交请求到底碰了哪几层。
▲ 本篇核心视觉:左侧是一次「提交」请求穿过的六层,右侧是用车主数据和两张单据的表关系,底部是流程状态与还车状态两条状态线。后面的每一节都能在这张图里找到位置。
引言:为什么用「用车」当案例
写后端文章最容易犯的错,是拿一个只有增删改查的玩具表做例子,读者看完依然不知道真实业务怎么落。企业项目的后端难点在于:主数据和单据怎么分、状态不止一种怎么管、审批流程怎么接、不同租户的数据怎么隔开。OA 用车模块恰好把这几件事都带上了,而且规模可控。
|
|
|
|
|---|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
说明:本文代码节选自仓库
ruoyi-office/yudao-module-oa,为便于阅读省略了部分空行与附件处理。表结构取自开发库的实际建表语句,字段含义以枚举和代码为准。
一、先建模:三个业务对象和两条状态线
动手写代码之前,先回答「这个模块里有哪些东西、它们有哪些状态」。用车模块有三个业务对象:
|
|
|
|
|
|---|---|---|---|
|
|
oa_car |
|
|
|
|
oa_car_apply_bill |
|
|
|
|
oa_car_return_bill |
|
|
先看系统里的车辆台账页面,这是主数据在界面上的样子:
▲ 车辆信息管理:车辆是主数据,状态只有「空闲」「停用」这类简单取值,和单据的审批状态是两回事。
单据的状态则复杂得多。一张用车申请单同时存在两条互不相同的状态线:
|
|
|
|
|
|---|---|---|---|
|
|
process_status |
|
|
|
|
return_status |
|
|
这个区分是后面所有设计的出发点:流程状态属于引擎,还车状态属于业务。业务模块不能把自己的业务状态也塞给流程引擎去管,否则审批一撤回,业务就乱了。
▲ 用车申请单列表:同一个列表里并存审批中、已撤回、审批通过和审批不通过,这些就是 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 生成
|
|
process_instance_id |
|
|
process_status |
|
|
return_status |
|
|
tenant_id |
|
|
dept_id
company_id
|
|
|
特别注意 return_status:建表注释是旧的,代码里的枚举 CarReturnStatusEnum 是 0 未生效、1 待还车、2 还车中、3 已还车。遇到注释和枚举冲突,永远以枚举和字典为准,并顺手修掉注释,否则下一个读代码的人会被误导。
三、分层:每一层只做一件事
OA 模块拆成 yudao-module-oa-api 和 yudao-module-oa-server。对外的枚举和错误码在 api,业务实现在 server。用车申请单涉及的文件如下:
|
|
yudao-module-oa 下)
|
|
|---|---|---|
|
|
oa-api/.../enums/ErrorCodeConstants
CarReturnStatusEnum、OaBillTypeEnum
|
1_101_000_xxx
|
|
|
oa-server/.../controller/admin/car/CarApplyBillController |
|
|
|
oa-server/.../controller/admin/car/vo/ |
|
|
|
oa-server/.../service/car/CarApplyBillServiceImpl |
|
|
|
oa-server/.../dal/mysql/car/CarApplyBillMapper |
|
|
|
oa-server/.../dal/dataobject/car/CarApplyBillDO |
|
对象转换这一层,官方规范里写的是 MapStruct 的 convert 包,但用车模块实际用的是 BeanUtils.toBean,目录里并没有 convert。这是读源码时要接受的现实:规范是方向,具体模块以代码为准,新模块选哪种方式要和团队约定一致。
四、Controller:权限、校验和统一返回
Controller 只做四件事:声明路径、声明权限、触发参数校验、包装返回。用车申请单的保存与提交是这样写的:
public class CarApplyBillController {private CarApplyBillService carApplyBillService;public CommonResult<Long> saveCarApplyBill( CarApplyBillSaveReqVO saveReqVO) {return success(carApplyBillService.saveCarApplyBill(saveReqVO));}public CommonResult<Long> submitCarApplyBill( CarApplyBillSaveReqVO createReqVO) {return success(carApplyBillService.submitCarApplyBill(createReqVO));}
这里有三个读代码时容易忽略的点:
- 权限标识的命名
是 模块:资源:动作,如oa:car-apply-bill:create。同一个字符串会出现在三个地方:Controller 的@PreAuthorize、菜单管理里按钮的「权限标识」、前端按钮的auth配置。第五篇会专门讲这条链。 - 「保存」和「提交」是两个接口、两个权限
。保存只落库不起流程,提交才进入审批。 - 并不是每个接口都标了权限
。 /get和/page没有@PreAuthorize,而专供审批页面嵌入的/get-for-bpm注释写明「不需要菜单权限,但会校验当前用户是否为流程参与者」。数据范围怎么兜底,是第五篇的内容。
VO 层也要如实说一件事:CarApplyBillSaveReqVO 里没有声明 @NotNull 等校验注解,@Valid 触发后实际没有字段级约束。目前必填由前端表单的 rules: 'required' 把关,后端只在 Service 里校验时间。如果你的新模块对数据质量要求高,建议在 VO 上补齐必填约束,不要只依赖前端。
时间字段是另一处约定:goTime 与 returnTime 在 VO 里是 LocalDateTime 且没有 @JsonFormat,按项目规范会以毫秒时间戳序列化,前端日期选择器需要配 valueFormat: 'x'。这个细节会在第四篇和前端代码一一对上。
五、Service:业务规则都在这里
Service 是整个模块的重心。提交用车申请要依次完成:生成单号、校验时间冲突、保存、提交流程、回写实例编号。核心方法如下(省略附件处理):
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 系列方法拼接条件,值为空时自动跳过,列表页的筛选就是这样落地的:
@Mapperpublic 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 ===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_statusFROM oa_car_apply_billWHERE process_status = 1AND (process_instance_id IS NULL OR process_instance_id = '');
八、闭环:一次提交从浏览器到数据库
把前面各层串起来,一次「提交用车申请」的完整链路如下:
▲ 时序图:权限与参数校验在 Controller,业务规则在 Service,租户条件在持久层自动拼接,流程提交通过 BpmProcessInstanceApi 完成,实例编号最后回写到单据。
|
|
|
|
|---|---|---|
|
|
POST /admin-api/oa/car-apply-bill/submit
|
|
|
|
oa:car-apply-bill:create 与 @Valid
|
|
|
|
|
CAR_TIME_CONFLICT
|
|
|
|
|
|
|
submitProcessInstance,businessKey 为单据 id
|
|
|
|
process_instance_id
|
|
|
|
CommonResult.success(id)
|
|
自己验证时不必写任何新代码:在 PC 端新建一张用车申请并提交,打开浏览器 Network,找到 submit 请求,响应里应该是成功码和一个数字 id;再用上面的 SQL 查最新一条记录,看 process_status 与 process_instance_id 是否同时有值。本文没有替你执行这些步骤,请在自己的环境里核对。
提交之后的页面长什么样?下图是一张已通过审批的用车申请单详情:
▲ 用车申请单详情:顶部是单据号、申请人和状态,下方的页签把业务数据与审批过程放在同一个页面里,这一页的前端实现放到第四篇讲。
还车申请单则是另一张单据,通过 apply_bill 指向用车单的 bill_code,并联动修改用车单的 return_status:
▲ 还车申请单列表:每张还车单都关联一张用车单,还车审批通过后,用车单才会变成「已还车」。
九、故障与验收矩阵
模块做完后,用下表自检,也可以在联调出现问题时当排查顺序:
|
|
|
|
|---|---|---|
|
|
|
oa:car-apply-bill:create 并已授权给角色
|
|
|
|
CAR_TIME_CONFLICT;查同一车辆的审批中与待还车单据
|
|
|
|
|
|
|
|
valueFormat: 'x'
|
|
|
|
|
|
|
|
updateProcessStatus
|
|
|
|
CAR_APPLY_BILL_ALREADY_RETURNED
|
十、第三天的学习结果
做完后端篇,建议逐项确认:
-
能区分主数据、业务单据、流程状态与业务状态,并说出用车模块里各对应什么。 -
能画出 Controller、Service、Mapper、DO、表五层,各自只负责什么。 -
能解释 businessKey、processDefinitionKey和process_instance_id三者的关系。 -
能说出「前端传 creator 无效」背后的原因,以及它为什么比前端过滤更可靠。 -
能指出本模块在事务边界、VO 校验和建表注释上的不足,并知道怎么避免在新模块里重复。
常见问题(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(获取产品咨询)
打开演示地址直接查看系统。

