
我一直觉得在OA系统这种内部管理系统里审批功能做得好不好直接影响整个公司的工作效率。尤其是像请假这种高频业务几乎每天都有员工在提审批人也在天天点。这篇继续讲我们的OA系统系列具体说说【请假审批】这个内嵌界面里的核心逻辑尤其是底层的待审批数据SQL该怎么写、怎么写才不容易出问题。很多刚接触OA开发的同事容易把待审批列表想简单了认为无非就是select * from leave_request where status 审批中。但实际等你理清审批流、数据权限、历史归档之后会发现一个看起来不起眼的待办清单背后其实是一个精巧的组合查询。这一篇主要解决三个问题审批功能在整个OA系统里的定位、待审批数据的业务定义和权限边界、以及最终落到SQL语句时的具体写法。我会把表结构也一并给出结合真实的开发场景说明每一步为什么这么设计。1. 审批功能在OA系统里到底解决了什么问题1.1 为什么说待审批列表是整个审批流的入口请假审批这个功能从用户视角来看其实就三个入口员工提交请假单、审批人查看待办并处理、申请人回看审批进度。而这其中审批人的待办列表又是使用频率最高、最容易出性能瓶颈的地方。你站在审批人的角度想一下每天一打开OA第一件事就是看一眼有没有需要我批的。这个列表如果查询慢、数据不准、权限混乱审批人第一反应就是系统不行。所以待审批数据的查询表面上看只是几个SQL语句的事情但从系统架构来说它是审批流的门面。从流程角度说一个完整的请假审批流大概分三段申请阶段员工填写请假类型、起止时间、天数、原因提交后生成一条请假单。流转阶段系统根据预设的审批规则把这条单据推给对应节点的审批人。审批人可以是直属上级、部门主管、人事专员等甚至可能有多级审批。归档阶段所有节点都处理完后单据变成已通过或已驳回进入历史存档不再出现在任何人的待办列表里。所以待审批数据在系统层面就是一个过程态数据要查询它就必须把流转阶段中那些当前节点轮到我处理的单据捞出来。1.2 待办清单、已办清单、我发起的为什么不能混在一起查我在给某企业做OA系统梳理时发现很多开发人员容易把三种列表数据搅在一起SQL里一个status字段走天下。但其实三种列表的业务语义是完全不同的待办清单我作为审批人有哪些单子需要我现在处理。条件是流程没结束、节点轮到我、我还没动作。已办清单我处理过的所有历史单据。条件是我在某个节点做过通过或驳回动作单据流转进入下一节点或结束。我发起的所有我提交的单据无论当前在哪个节点、什么状态。条件只看申请人和单据状态。这三种列表如果混在一个逻辑里处理会导致两个问题一是SQL里到处是if else分支极难维护二是查询性能很难优化因为不同列表侧重的维度和索引都不一样。我们这次设计的【请假审批】内嵌界面严格把待办清单独立出来。标题里提到的待审批请假数据就是这一部分它的SQL也会单独隔离出来不跟已办、我发起的混在一起。这样从根上避免了后续改一处崩三处的问题。2. 待审批数据的业务定义别再用状态字段模糊查询糊弄自己2.1 一条数据称得上待审批必须同时满足三个硬条件我们在设计SQL之前先把业务定义定清楚。所谓待审批请假数据就是一条请假单满足下面三个条件单据当前处于审批中状态而不是草稿、已通过、已驳回。这一条对应请假单主表的status字段。单据当前流转到的审批节点恰好包含当前操作人这个人。注意这里不一定是单人可能有多个审批人并行处理比如部门负责人这个角色下有三个人那这三个人都应该看到这条待办。当前操作人在这个节点上还没有做任何动作。也就是说在审批记录表里这条单据针对这个审批人的最新记录是待审批状态而不是已通过或已驳回。这三条缺一不可。我见过不少项目只判断了第一条和第三条忽略了第二条结果就是审批人A可以看到B的单据或者某单据从第二个节点退回到第一个节点后第一个节点的人看不到这条单。这里我把三个条件再拆细一点后面写SQL的时候会对应上条件对应字段/表说明单据处于审批中请假单主表status只有审批中状态的单子才需要被处理当前节点包含操作人审批记录表approver_id候选审批人才能看到这条待办操作人还没有审批动作审批记录表action 待审批动作是0或1由具体设计决定但要有这个区分2.2 权限边界A部门主管看不到B部门的请假单待审批数据还有一个躲不开的问题就是数据权限。同样是审批人角色A部门主管和B部门主管能看到的待办范围完全不同。如果OA系统是集团级别的还涉及多公司、多组织层级的数据范围。我们把权限边界分成了三种级别本人级只能看到自己提交的单据。申请人查询自己的单子时用。部门级能看到本部门所有员工的单据。部门主管审批时范围限定在自己部门内。角色级比如人事专员、总经理可以看到全公司所有相关单据。在待审批SQL里权限过滤通常不是叠加在approver_id上而是叠加在申请人所属部门上。比如部门主管要看到本部门的请假单SQL里就需要额外加一个部门过滤条件lr.dept_id 当前用户的部门ID。如果不加这个条件一个主管就能看到全公司所有人的待办这绝对是事故级别的权限漏洞。另外这里我建议不要直接在SQL里用dept_id做硬编码最好从用户上下文里取。一般来说系统都会把当前登录人、所属部门、角色列表放到一个上下文对象里SQL参数直接传进去就行。2.3 为什么不能只用status字段做过滤有些开发人员会把待办逻辑简化成WHERE status 审批中。这样做的隐患我在实际项目里见过不止一次。原因很简单status描述的是单据整体的生命周期状态但它不包含当前流转到哪个节点和谁应该处理这两个关键信息。举个例子一张请假单是三级审批当前已经过了第一级部门主管正在等第二级总监审批。此时单据的status是审批中没错但对部门主管来说这张单已经处理过了不应该再出现在他的待办里。如果只查status部门主管会一直看到这张单直到流程完全结束。所以要精确判断我是否需要处理这张单必须把单据状态和审批记录结合起来通过审批记录定位到当前节点是否有我的记录、我的记录是不是待审批状态。这个理解是整个待审批SQL的核心也是标题里说的显示【待审批请假数据】的SQL语句之所以要单独拎出来讲的原因。它不是一个简单的主表查询而是一个涉及业务主表、审批记录表的联合条件查询。3. 支撑待办查询的三张表与字段设计3.1 请假单主表业务数据的唯一载体写待办SQL之前表结构是绕不开的。我们这边用的是三张核心表请假单主表、审批记录表、审批流程配置表。先看请假单主表字段大致如下CREATE TABLE leave_request ( id bigint NOT NULL COMMENT 请假单ID, request_no varchar(32) NOT NULL COMMENT 申请编号如QJ20250101001, user_id bigint NOT NULL COMMENT 申请人ID, dept_id bigint NOT NULL COMMENT 申请人所属部门ID, leave_type tinyint NOT NULL COMMENT 请假类型1事假 2病假 3年假 4调休, start_time datetime NOT NULL COMMENT 请假开始时间, end_time datetime NOT NULL COMMENT 请假结束时间, total_days decimal(5,1) NOT NULL COMMENT 请假总天数, reason varchar(500) DEFAULT NULL COMMENT 请假原因, status tinyint NOT NULL DEFAULT 1 COMMENT 状态1草稿 2审批中 3已通过 4已驳回, create_time datetime NOT NULL COMMENT 提交时间, update_time datetime NOT NULL COMMENT 更新时间, PRIMARY KEY (id), KEY idx_user (user_id), KEY idx_dept (dept_id), KEY idx_status (status), KEY idx_create_time (create_time) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT请假单主表;这张表的主键是id唯一业务编号是request_no但查询待办列表时一般用不上request_no更多是用dept_id和status过滤。索引方面status和dept_id是待办列表最常用的过滤字段必须建索引。3.2 审批记录表当前节点的关键在于is_current标记审批记录表是待办查询的核心。我们给它起名叫approval_record每次审批节点的创建、通过、驳回都会写一条记录。字段如下CREATE TABLE approval_record ( id bigint NOT NULL COMMENT 记录ID, business_id bigint NOT NULL COMMENT 业务单据ID这里指请假单ID, step int NOT NULL COMMENT 审批步骤序号从1开始, approver_id bigint NOT NULL COMMENT 审批人ID, action tinyint NOT NULL DEFAULT 0 COMMENT 动作0待审批 1通过 2驳回, opinion varchar(500) DEFAULT NULL COMMENT 审批意见, operate_time datetime DEFAULT NULL COMMENT 操作时间待审批时为空, is_current tinyint NOT NULL DEFAULT 1 COMMENT 是否当前有效节点1是 0否, create_time datetime NOT NULL COMMENT 记录创建时间, PRIMARY KEY (id), KEY idx_business (business_id), KEY idx_approver (approver_id), KEY idx_current_action (is_current, action) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT审批记录表;这里有一个很关键的设计is_current字段。很多系统的审批记录表没有这个字段查询当前节点时只能通过step (select max(step) ...)这种方式来定位性能和SQL复杂度都很高。加一个is_current标记之后每次审批动作发生时我们把当前节点的所有记录is_current置为0再把新节点的记录插入并标记为1。这样查询待办时只需要过滤is_current 1即可SQL简单、索引命中率高。另外每张单据的每个节点可以有多条审批人记录。比如节点2是人事专员审批人事部门有两个人那这个节点就会插入两条approval_record这两条的step相同、is_current都为1但approver_id不同。这样一张请假单就会出现在两个候选审批人的待办列表里谁先处理另一条记录就变成历史。3.3 审批流程配置表节点与审批人的映射审批流程配置表用来定义不同请假类型、不同条件走哪个审批路径。它不直接参与待办SQL的查询但决定了审批记录表里节点的生成逻辑。表结构如下CREATE TABLE approval_flow ( id bigint NOT NULL COMMENT 配置ID, business_type tinyint NOT NULL COMMENT 业务类型1请假 2加班 3报销, leave_type tinyint DEFAULT NULL COMMENT 请假类型为空表示适用所有类型, step int NOT NULL COMMENT 审批步骤序号, approver_type tinyint NOT NULL COMMENT 审批人类型1指定角色 2指定部门 3指定人, approver_value varchar(255) NOT NULL COMMENT 审批人标识角色ID/部门ID/用户ID, PRIMARY KEY (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT审批流程配置表;这张表的具体使用方式是在提交请假单时根据business_type和leave_type匹配出所有节点然后依次生成approval_record记录。待审批SQL不需要直接关联它但了解这张表能帮你理解审批记录从哪来。尤其是调试审批人为什么不对这种问题时翻配置表通常能快速找到原因。4. 待审批请假数据的SQL语句设计从能跑到跑得快4.1 基础版SQL一次性关联搞定待办清单条件理顺了表也设计好了接下来就是重头戏待审批请假数据的SQL写法。我先给出基础版这个版本能跑、语义也正确适合刚上手的项目。SELECT lr.id, lr.request_no, lr.leave_type, lr.start_time, lr.end_time, lr.total_days, lr.reason, lr.create_time, u.real_name AS applicant_name, d.dept_name AS applicant_dept, ar.step AS current_step, ar.approver_id FROM leave_request lr INNER JOIN approval_record ar ON ar.business_id lr.id INNER JOIN sys_user u ON u.id lr.user_id LEFT JOIN sys_dept d ON d.id lr.dept_id WHERE ar.approver_id #{currentUserId} AND ar.is_current 1 AND ar.action 0 AND lr.status 2 ORDER BY lr.create_time DESC这个SQL看起来简单但每一行都有它存在的意义。核心过滤条件就四个ar.approver_id #{currentUserId}限定当前登录人。ar.is_current 1只查当前有效节点。ar.action 0当前节点下我还没有动作的。lr.status 2单据确实处于审批中。注意我用了INNER JOIN approval_record而不是LEFT JOIN这也是有讲究的。因为一个过滤条件全部命中才会返回数据用LEFT JOIN没有意义反而可能因为多个审批记录产生重复数据。4.2 用EXISTS改写的待办查询更贴近语义的另一种写法基础版SQL用JOIN实现优点是直观缺点是如果审批记录表数据量很大JOIN可能比EXISTS慢。我提供第二种写法也更贴合是否存在一条记录这种业务语义SELECT lr.id, lr.request_no, lr.leave_type, lr.start_time, lr.end_time, lr.total_days, lr.reason, lr.create_time, u.real_name AS applicant_name, d.dept_name AS applicant_dept FROM leave_request lr INNER JOIN sys_user u ON u.id lr.user_id LEFT JOIN sys_dept d ON d.id lr.dept_id WHERE lr.status 2 AND EXISTS ( SELECT 1 FROM approval_record ar WHERE ar.business_id lr.id AND ar.approver_id #{currentUserId} AND ar.is_current 1 AND ar.action 0 ) ORDER BY lr.create_time DESC这种写法在逻辑上更容易扩展。比如如果后续要增加审批人所在部门与申请人部门一致这样的条件你只需要在EXISTS子查询里加一个关联不会影响主查询的结构。4.3 加上部门数据权限A部门主管只能看A部门接下来是权限版本。如果当前登录人是部门主管他只应该看到自己部门的请假单。这时在基础版SQL里追加一个条件SELECT lr.id, lr.request_no, lr.leave_type, lr.start_time, lr.end_time, lr.total_days, lr.reason, lr.create_time, u.real_name AS applicant_name, d.dept_name AS applicant_dept, ar.step AS current_step FROM leave_request lr INNER JOIN approval_record ar ON ar.business_id lr.id INNER JOIN sys_user u ON u.id lr.user_id LEFT JOIN sys_dept d ON d.id lr.dept_id WHERE ar.approver_id #{currentUserId} AND ar.is_current 1 AND ar.action 0 AND lr.status 2 AND lr.dept_id #{currentUserDeptId} ORDER BY lr.create_time DESC这条SQL比基础版多了lr.dept_id #{currentUserDeptId}这是部门级数据权限。如果系统还支持本部门及下级部门那这里就要换成lr.dept_id IN (自己部门 子部门集合)子部门集合可以一次性从部门表里查出来放到内存里。这里我顺便提一个容易踩的坑部门权限过滤不要加到approval_record表的approver_id上因为审批人可能是跨部门指定的。比如A部门的员工请假审批人却是B部门的总监如果按审批人部门过滤这条单就被错误过滤掉了。正确做法是始终用申请人所属部门作为数据权限的过滤维度。4.4 待办数量的统计SQL首页红点不能慢很多OA首页会显示一个待办数量的小红点比如审批(5)。这个数字如果每次打开都重新统计性能压力不小。我们的做法是提供一个独立统计SQLSELECT COUNT(*) FROM leave_request lr WHERE lr.status 2 AND EXISTS ( SELECT 1 FROM approval_record ar WHERE ar.business_id lr.id AND ar.approver_id #{currentUserId} AND ar.is_current 1 AND ar.action 0 )这个SQL只返回一个整数逻辑跟列表查询保持一致不会出现列表显示6条红点显示8条这种尴尬。后面我再补充索引优化时这个COUNT查询也要一起考虑。4.5 列表展示字段的补充查询待办列表里除了请假单本身通常还要展示申请人的姓名、部门名称、请假类型的中文含义等。这些字段要么在SQL里直接JOIN关联表要么在Java代码里用批量ID查询后再装配。我建议优先SQL里JOIN减少查询次数。像sys_user、sys_dept这种基础表都有索引JOIN成本很低。另外请假类型这种枚举字段我一般不会在SQL里做CASE WHEN转换原因是枚举逻辑可能会变放在代码里维护更灵活。SQL只返回leave_type的原始值界面层再根据字典翻译成事假、病假等。5. 实测中踩过的坑索引、事务与状态一致性5.1is_current标记的维护与并发问题在写审批记录表时加is_current字段确实带来了查询上的便利但代价是更新逻辑一定要做对。每次审批动作发生时需要把当前节点的所有is_current 1记录置为0再插入新记录。这两个操作必须放在同一个数据库事务里否则一旦中间出现异常就会出现两个节点同时是当前节点的数据错误。我们的审批操作核心逻辑大致是Transactional public void approve(Long recordId, Integer action, String opinion) { // 1. 查询当前记录校验是否待审批状态 ApprovalRecord record approvalRecordMapper.selectById(recordId); // 2. 更新当前记录的动作、意见、操作时间 record.setAction(action); record.setOpinion(opinion); record.setOperateTime(new Date()); approvalRecordMapper.updateById(record); // 3. 将当前节点所有is_current1的记录置为0 approvalRecordMapper.disableCurrentByBusinessId(record.getBusinessId()); // 4. 如果流程未结束插入下一个节点的审批记录 if (hasNextStep(record.getBusinessId(), record.getStep())) { approvalRecordMapper.insertNextStepRecords(...); } else { // 5. 流程结束更新主表单状态 leaveRequestMapper.updateStatus(record.getBusinessId(), 3); } }这里有个隐蔽问题如果recordId是当前节点多条记录中的一条而当前节点有多个审批人那么某个审批人操作后其他审批人的关联记录也会被一起置为is_current 0。数据逻辑上这样设计是对的因为节点只要被其中一人处理该节点就不再是当前节点了。但对并行审批这种业务规则比如要求所有审批人都必须同意就不能用这个方案了。我们这边请假审批采用任一审批人处理即通过的规则所以is_current维护方式没有问题。5.2 索引设计哪些字段必须进组合索引待办查询的性能瓶颈主要在审批记录表上。我们线上数据量在百万级之后单表查询还能保持在几十毫秒内靠的就是组合索引。审批记录表建议建一个联合索引最核心的是下面这两个ALTER TABLE approval_record ADD INDEX idx_business_current_action (business_id, is_current, action), ADD INDEX idx_approver_current_action (approver_id, is_current, action);第一个索引(business_id, is_current, action)用于根据单据查当前节点记录第二个索引(approver_id, is_current, action)用于待办查询因为我们的SQL就是按approver_id is_current action三个条件过滤的。如果没有这个联合索引数据库会先按approver_id筛出一堆历史记录再过滤is_current速度会慢很多。请假单主表方面status、dept_id、create_time单独建索引即可。有些项目还会遇到按时间范围统计数据的需求比如最近七天的请假人数这时候可以考虑(dept_id, create_time)联合索引。5.3 大偏移量分页的优化方案待办查询列表一定会做分页LIMIT 10 OFFSET 50000这种写法在数据量大了以后会越来越慢。原因在于数据库需要扫描前面50000行再丢弃效率极低。我们这边的优化方案是改成基于游标的分页或者叫键集分页。简单说就是排序字段不要用create_time之余还用OFFSET而是记住上一页最后一条记录的时间下一页直接从那个时间点往后再取SELECT lr.id, lr.request_no, lr.create_time FROM leave_request lr INNER JOIN approval_record ar ON ar.business_id lr.id WHERE ar.approver_id #{currentUserId} AND ar.is_current 1 AND ar.action 0 AND lr.status 2 AND lr.create_time #{lastCreateTime} ORDER BY lr.create_time DESC LIMIT 10第一次查询时lastCreateTime传当前时间即可之后每次把上一页最后一条的create_time传进去。这种方式在表数据量很大的情况下性能非常稳。它唯一的限制是要求排序字段没有重复值或重复值少create_time在极端情况下可能重复但配合id就可以避免AND (lr.create_time #{lastCreateTime} OR (lr.create_time #{lastCreateTime} AND lr.id #{lastId})) ORDER BY lr.create_time DESC, lr.id DESC这样两列一起排序既保证数据不重不漏又彻底告别了OFFSET。5.4 审批记录与主表单的状态一致性再强调一个非常容易被忽略的问题审批记录表更新和请假单主表状态更新必须保证一致性。比如流程最后一步审批通过后需要把主表的status从2审批中改成3已通过。如果这两个操作不在同一个事务里可能出现审批记录已经显示通过但主表状态还是审批中的脏数据。这种脏数据一旦出现最典型的表现就是待办列表里那条单永远消不掉而且审批人已经找不到处理入口了。修复起来虽然不难但要排查却非常浪费时间。所以我在项目里一直坚持凡是涉及审批动作的业务方法必须加上Transactional并在代码注释里写明事务边界。这不是多此一举而是为了在三个月后或者换人维护时还能一眼看明白哪些操作是一个整体。最后说一句审批功能看起来只是OA系统里的一个小模块但做深了你会发现它其实涉及流程引擎、权限模型、数据一致性、查询性能等多个方面的设计取舍。这一篇我们先把【请假审批】界面的待审批数据SQL讲清楚下一篇再展开审批通过、驳回后的状态流转处理以及审批历史的展示逻辑。
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。