做OA系统做到第二十个模块核心其实就两个字审批。而请假审批又是审批模块里最典型的一个——单子本身不复杂无非是员工填申请、主管点同意、人事做归档但偏偏就是这种看着简单的功能牵扯到的角色权限、状态流转、SQL查询最容易出幺蛾子。这一篇我准备把【请假审批】这个内嵌界面里最核心的数据查询——【待审批请假数据】的SQL语句从表结构设计到业务逻辑一路拆到底。前面先讲清楚审批功能的设计思路后面直接给出能抄的SQL和排查经验的清单。先说明一下适用场景。这套东西在某个公司的OA项目里已经跑了两三年技术栈是Spring Boot MyBatis 前端iframe内嵌页面数据库用的MySQL。所以你会看到分页用的LIMIT参数传的#{}Mapper里写的是XML。如果你的项目用了其他ORM或者非MySQL数据库思路完全一样SQL方言稍微改改就行。1. 请假审批功能模块的整体定位1.1 审批流在OA系统里到底管什么很多人一听到请假审批第一反应就是这不就是一个表单加一个状态字段吗。确实单看请假这个场景业务上就是这么回事。但把请假审批放到整个OA系统里它的意义就完全不一样了——它是审批流这套机制的第一个标准化落地场景。我在做这个模块之前OA系统里已经有员工管理、考勤打卡、流程引擎这几个基础模块。到了第二十个模块安排请假审批本质上是把流程引擎真正用起来。请假的审批链路是申请人提交、部门主管审批、HR确认、归档链路不长但每一步都有明确的角色边界、状态变化和历史记录要求。把这个链路跑通后面再做报销审批、用章申请、加班申请几乎就是同一套代码换个表单的事。这也是我特别看重待审批数据这个查询的原因。它不只是把表里的数据捞出来它代表了当前登录人此刻要处理的所有任务。这个查询在OA里几乎是所有审批类模块通用的请假审批要查待审批报销审批也要查待审批只是查询的明细字段不同。所以这个SQL写得对不对、快不快、权限隔离严不严直接决定了整个审批体系的地基质量。1.2 请假审批涉及的核心角色与数据权限在做表结构和SQL之前先把角色定清楚。请假审批这个界面服务四类人普通员工可以发起请假申请查看自己提交过的单子状态在审批人还没处理时可以撤销。部门主管处理本部门或者自己管辖的下属提交的请假单可以同意也可以驳回。人事考勤岗查看所有审批通过的单子核对考勤、计算假期余额、处理异常。系统管理员维护请假类型、配置审批链路、调整审批人。这四类人看到的数据范围完全不一样。普通员工只能看自己的部门主管只能看自己下属的人事看到的是全量。但是有一点很关键即使我有管理员的越权查看能力待审批请假数据永远只属于当前审批人本人。也就是说这个列表的过滤条件不能简单地写成status审批中。一个员工提交之后如果当前流程节点在部门主管那里那这条数据只出现在部门主管的待审批列表里申请人自己、人事、甚至更高层领导都不应该在这个待审批列表里看到它。这个逻辑如果没想清楚后面就会做出那种审批人能看到所有人待审批单子的权限事故。1.3 审批状态机的设计思路请假单的状态我这边用了一个tinyint字段定义如下状态值含义触发动作0草稿员工填写未提交1审批中员工提交2已通过最后一个审批节点通过3已驳回任一审批节点驳回4已撤销审批中时申请人主动撤销状态流转很简单提交的时候从0跳到1审批通过时如果还有下一节点就继续保持1把current_approver指向下一个审批人如果已经是最后一个节点就变成2任意节点驳回直接变成3在1状态下申请人可以撤销变成4。这里有个很重要的设计点不要把审批通过直接理解成一次操作就完事。请假审批如果只有一层主管审批那确实从1直接到2。但如果公司规定请假超过3天要总经理审批那么主管通过之后这条单子仍然处于审批中只是current_approver从主管换成了总经理。也就是说状态为1的单子里既包括还没到我这边的也包括正在等我审批的。待审批列表必须区分这两类否则就会把已经在我上级那里流转的单子也显示给我。这个区分靠的就是下一节要讲的current_approver字段或者更规范一点靠一张独立的审批任务表。我们项目里一开始用的是字段方案后来说要支持复杂的加签、转派就补了一张审批记录表配合使用。但不管哪种方案当前登录人当前节点处理人这个过滤条件都是唯一的。2. 待审批数据查询的SQL拆解2.1 先搞清楚待审批的业务含义写SQL之前先把待审批请假数据翻译成逻辑条件这一步比写SQL本身更重要。一条请假单要在待审批列表里出现必须同时满足三个条件这条单子是已经提交并且在流转中的status 1。草稿、已通过、已驳回、已撤销的都不要。当前流程节点正等在当前登录人手上也就是current_approver 当前登录人ID。这里注意不是applicant_id等于登录人而是处理人等于登录人。如果有审批记录表还要保证这个登录人没有重复处理过这条单子或者已经从中间环节被替换掉。把这些条件想透了SQL骨架就出来了一个主表查询加上必要的关联表过滤条件就两个核心——status和current_approver然后按提交时间倒序。别小看这两个条件实际开发里80%的待审批列表数据不对问题都出在这个过滤条件写歪了。2.2 数据库表结构与关键字段设计这个查询涉及四张表先列出关键字段方便后面看SQL的时候对照。请假申请表 t_oa_leave_apply 是主表每个字段都影响查询结果字段类型说明apply_idbigint主键apply_novarchar(32)申请单号例如LV20250601001applicant_idbigint申请人IDleave_type_idint请假类型关联请假类型表begin_timedatetime请假开始时间end_timedatetime请假结束时间leave_daysdecimal(4,1)请假天数支持半天0.5reasonvarchar(500)请假事由statustinyint0草稿 1审批中 2已通过 3已驳回 4已撤销current_approverbigint当前节点审批人IDcreate_timedatetime提交时间update_timedatetime更新时间员工表 t_oa_employee 提供申请人姓名和所属部门字段类型说明emp_idbigint员工IDemp_namevarchar(50)员工姓名dept_idbigint部门ID部门表 t_oa_dept 提供部门名称字段类型说明dept_idbigint部门IDdept_namevarchar(50)部门名称请假类型表 t_oa_leave_type字段类型说明type_idint类型IDtype_namevarchar(20)类型名称例如事假、病假、年假annual_flagtinyint是否消耗年假余额审批记录表 t_oa_approval_record字段类型说明record_idbigint记录IDapply_idbigint请假申请IDapprover_idbigint审批人IDapproval_levelint第几级审批approval_resulttinyint1通过 2驳回approval_commentvarchar(300)审批意见approval_timedatetime审批时间2.3 核心SQL语句与逐行解析先给最终版本一条列表查询一条计数查询这两条是待审批界面加载时的第一波请求。SELECT la.apply_id, la.apply_no, e.emp_name AS applicant_name, d.dept_name, lt.type_name, la.begin_time, la.end_time, la.leave_days, la.reason, la.create_time FROM t_oa_leave_apply la INNER JOIN t_oa_employee e ON e.emp_id la.applicant_id INNER JOIN t_oa_dept d ON d.dept_id e.dept_id INNER JOIN t_oa_leave_type lt ON lt.type_id la.leave_type_id WHERE la.status 1 AND la.current_approver #{loginUserId} ORDER BY la.create_time DESC LIMIT #{offset}, #{pageSize}SELECT COUNT(*) FROM t_oa_leave_apply la WHERE la.status 1 AND la.current_approver #{loginUserId}拆开说。SELECT部分有三个信息维度是用户最关心的谁请的假申请人、部门、请的什么假类型、起止时间、天数、为什么请假事由。所以要把请假主表关联到员工表拿到姓名再关联到部门表拿到部门名称再关联到请假类型表拿到类型中文名。为什么不把员工姓名、部门名称直接冗余在请假表里我早期图省事干过这事结果员工改名或者部门调整之后历史审批单上显示的还是老信息人事查账的时候对不上被吐槽过好几次。用JOIN实时关联虽然多几次查表但能保证展示的数据永远跟员工最新档案一致。当然如果你的OA系统特别看重审批单的历史快照那就在提交申请时把当时的姓名部门冗余进去这也是一种合理设计两条路都有取舍。WHERE部分两个条件我在前面已经解释过这里补充一个细节current_approver比对的是当前登录人的ID。在实际接口里登录人ID是从服务端Session或者Token里解析出来的不是前端传过来的参数。谁传谁信谁就等着被越权。我见过有人为了图方便把当前用户ID也放到前端请求参数里这在内部OA里风险不大但一旦系统要对外或者接入移动端这就是妥妥的漏洞。ORDER BY create_time DESC让最新的请假单排在最上面这是审批场景的固定操作。审批人打开列表肯定先处理刚提交的这点不用多说。LIMIT分页是MyBatis里最常规的写法。offset从(pageNum-1)*pageSize算出来pageSize一般是10、20或者50我在请假审批列表里用的20一屏放得下审批人不用翻太多次。这条SQL里还有一个容易翻车的点t_oa_employee和t_oa_dept用的是INNER JOIN如果某个员工被删除了或者部门被解散了对应的请假单会从列表里消失。审批人看到列表突然少了一条第一反应就是数据丢了。这种情况我推荐在主查询里保持关联查询不变但在后台管理里不允许物理删除员工和部门只做逻辑删除打标记。后面排查问题那节我会再展开说一次。2.4 索引设计为什么这条SQL必须快待审批列表是一个高频率查询。审批人打开页面就触发一次点击审批完成之后列表要刷新前端轮询还会定时刷。如果每次查询全表扫描等到请假单累积几万条的时候接口延迟会从几十毫秒飙到几百毫秒审批人就会开始骂系统卡。好在过滤条件非常固定就两个字段status和current_approver。直接给这两个字段建一个联合索引就够了CREATE INDEX idx_status_approver ON t_oa_leave_apply(status, current_approver);联合索引的顺序有讲究。等值过滤条件都放在前面最左前缀原则决定status在前current_approver在后这样先按status过滤掉绝大多数历史数据再按当前审批人精确匹配。如果反过来建每个审批人ID下面都要扫描所有状态的数据效果差一大截。ORDER BY create_time DESC这个排序没法完全靠索引避免因为排序字段不在上面的联合索引里。好在经过两个等值条件过滤之后结果集已经很小MySQL用filesort排序也就几十条数据开销完全可以接受。如果以后数据量进一步增长可以考虑在联合索引里把create_time带进去做成(status, current_approver, create_time)让索引直接覆盖排序。对了用EXPLAIN验证一下EXPLAIN SELECT la.apply_id, la.apply_no, e.emp_name AS applicant_name FROM t_oa_leave_apply la INNER JOIN t_oa_employee e ON e.emp_id la.applicant_id WHERE la.status 1 AND la.current_approver 10086 ORDER BY la.create_time DESC LIMIT 0, 20;type应该是refkey用上了idx_status_approverrows预估的数量不会超过这个审批人名下正在审批的单子数。如果看到type是ALL或者key是空的说明索引没建上或者查询条件里对字段做了函数运算。比如你写成了WHERE status 1 AND current_approver 0 10086这就是典型的让索引失效的写法。别问我为什么知道我踩过。3. 内嵌界面的数据交互逻辑3.1 前端菜单与iframe装载题目里提到的内嵌界面在咱们这个OA项目里就是主框架右侧的内容区用iframe嵌套了一个独立的请假审批页面。主菜单点击请假审批框架动态拼一个URL塞进iframe的src里。这个URL指向请假审批模块的首页页面上方是几个Tab默认选中的就是待审批。待审批Tab加载时页面JS立刻发起一个Ajax请求调用后端的列表接口。这个接口取名/leave/approve/pendingListGET方法参数就是pageNum和pageSize。后端从当前会话取登录人ID拼上分页参数去查MyBatis的MapperMapper执行的就是上一节那条SQL。内嵌页面这种方式在传统OA里非常普遍好处是开发简单、模块之间隔离得干净、不会因为主框架升级把子功能搞崩。缺点是iframe之间的通信麻烦点比如审批完成后要刷新另一个iframe里的考勤汇总就得靠父页面的消息总线转发一下。这里不展开只提示别再去用top.location这种老掉牙的做法用postMessage是正路。3.2 列表接口与分页参数的使用后端接口我给出核心骨架实际项目里Controller、Service、Mapper三层。Controller层只做参数接收和会话解析GetMapping(/leave/approve/pendingList) public ResultPageResultLeaveApplyVO pendingList( RequestParam(defaultValue 1) Integer pageNum, RequestParam(defaultValue 20) Integer pageSize, HttpServletRequest request) { Long loginUserId SessionUtils.getCurrentUserId(request); PageResultLeaveApplyVO page leaveApproveService.queryPending(loginUserId, pageNum, pageSize); return Result.success(page); }Service层算好offset调用Mapperpublic PageResultLeaveApplyVO queryPending(Long loginUserId, Integer pageNum, Integer pageSize) { int offset (pageNum - 1) * pageSize; ListLeaveApplyVO list leaveApplyMapper.selectPendingList(loginUserId, offset, pageSize); Long total leaveApplyMapper.countPending(loginUserId); return new PageResult(list, total); }Mapper XML里就是上一节的SQL注意count查询里不需要JOIN那些表。因为COUNT(*)只需要知道满足过滤条件的行数关联员工表反而会增加无关的开销。等数据量大之后SELECT COUNT(*)这种查询也可以用定时任务缓存各审批人待办数不过这是后话。有个细节容易忽略分页参数要加默认值否则前端一个请求漏传pageSize后端就要么报错要么返回空。我在项目里统一用RequestParam的defaultValue兜底遇到参数缺失也能正常响应。3.3 与详情页、审批动作的数据联动待审批列表只能看到摘要信息。审批人要看完整请假单得点行打开详情。详情数据来自另一个接口SELECT la.apply_id, la.apply_no, e.emp_name, d.dept_name, lt.type_name, la.begin_time, la.end_time, la.leave_days, la.reason, ar.approver_id, ar.approval_comment, ar.approval_time FROM t_oa_leave_apply la LEFT JOIN t_oa_employee e ON e.emp_id la.applicant_id LEFT JOIN t_oa_dept d ON d.dept_id e.dept_id LEFT JOIN t_oa_leave_type lt ON lt.type_id la.leave_type_id LEFT JOIN t_oa_approval_record ar ON ar.apply_id la.apply_id WHERE la.apply_id #{applyId}这里用的是LEFT JOIN因为审批记录可能还没有刚提交还没人处理过不能用INNER JOIN把主单丢掉。如果想让审批历史按时间正序展示可以再加ORDER BY ar.approval_time ASC这个排序在审批记录很少的情况下几乎没有开销。审批人点同意或驳回之后前端调审批接口后端在一个事务里做三件事往审批记录表插一条数据、更新请假单的current_approver指向下一个节点、必要时更新status。事务提交成功后前端刷新待审批列表这条数据立刻从列表里消失。如果它出现在已审批列表里说明状态推进成功。整个过程要保证原子性否则会出现审批记录有了但列表里的单子还在这种让审批人点第二次的尴尬情况。我在这个环节吃过一次大亏。当时审批接口和记录插入分了两个事务审批人点同意后记录插入失败但状态更新成功导致这条单子在待审批列表里消失在已审批列表里也找不到完全变成了幽灵单。最后是让运维翻数据库日志才捞回来。从那之后我再也没有把状态更新和审批记录分到两个独立事务里写过。4. 实操中的常见问题与排查实录4.1 明明有数据却查不到这是待审批列表最经典的问题某个审批人打开页面总感觉少了几条单子。我自己排查过的原因有三种。第一种状态值约定不一致。有的人把已提交定成2有的人把审批中定成1前端传过来的status和数据库里实际存的status对不上导致status 1查出来的是另一批数据。这种问题查的方法是直接看库里一条已知单子的status值再比对SQL里的条件。第二种current_approver没被正确写入。流程引擎在提交申请时如果没把第一个审批人写到current_approver字段里那么这条单子status1但current_approverNULL任何审批人都查不到它。这个字段为空的时候我建议在提交接口里做防空判断一旦发现为空直接报错并提示该员工没有配置审批人。第三种JOIN把我坑了。员工如果离职被物理删除或者部门被物理删除INNER JOIN会把对应的请假单整个过滤掉。待审批列表看起来少数据其实数据在只是被关联表的残缺拖下水了。所以我在项目里定了一条硬规矩业务主档一律不物理删除全部用一个deleted_flag逻辑删除。这条规矩救了我后面很多次。4.2 审批人权限串号权限串号的典型表现是登录人A的待审批列表里出现了本应只属于登录人B的单子。排查的时候先看SQL的WHERE条件十有八九是条件写成了WHERE la.status 1 AND la.applicant_id #{loginUserId}这一行把当前审批人登录人写成了申请人登录人结果就是普通员工看到自己提交的单子里那些还没被审批的单子而不是他们需要处理的单子。员工看到后一脸懵审批人漏处理了一堆单子却不知道。还有一种更隐蔽的情况数据权限虽然写在查询里了但Controller直接从请求参数里接收了userId导致有人手工构造请求就能查询别人的待办。我在前面已经强调过这个字段必须从服务端会话里取。这个问题的排查办法很简单用A的账号登录把请求里的userId参数改成B的ID如果接口返回了B的数据那这个接口就是有权限漏洞的必须改。4.3 状态字段更新丢失状态更新丢失通常表现为审批人明确点了同意但下次打开待审批列表这条单子还在。很多人第一反应是SQL更新没生效其实我遇到过两次真正的原因。一次是并发问题。审批人在两个标签页打开了同一个详情页同时点了同意。代码里的更新语句长这样UPDATE t_oa_leave_apply SET status 2, current_approver NULL WHERE apply_id #{applyId} AND status 1两个请求同时进来第一个把status改成了2第二个因为WHERE条件里加上了status 1更新了0行也不会把审批记录再插一遍。这就是乐观锁的思想用旧状态作为更新条件保证同一条单子不会被审批两次。如果没加这个条件两个请求都执行成功审批记录表里就会多出一条重复记录后续统计全乱。另一次是缓存问题。早期前端列表缓存了接口数据审批完成后只刷新了当前页但缓存里的数据还是旧的。这不是后端Bug是前端没做好失效。解决办法是在审批成功的回调里拿到返回结果后强制调用一次列表刷新接口同时清掉本地缓存。还有个很容易被忽略的细节审批完成之后如果current_approver字段没有更新成下一个审批人或者置空这条单子会一直出现在原审批人的待审批列表里。我在代码里是这么设计的如果还有下一节点就把current_approver改成下一节点审批人如果这是最后一节点就把current_approver改成NULL。这个字段要么指向一个真实的人要么就是NULL绝不能保留原来的审批人ID。4.4 慢查询优化记录最后说一个真实遇到的慢查询案例。系统上线半年之后请假数据到了三四万条某个大部门的审批人打开待审批列表接口要两秒多才返回。查了日志发现列表查询走了全表扫描。当时的SQL和索引情况t_oa_leave_apply表只有主键索引列表SQL的WHERE条件还是status和current_approver两个字段完全没有索引可用。EXPLAIN结果typeALLrows显示三万多行这就解释了为什么慢。处理办法就是建索引。我建了CREATE INDEX idx_status_approver ON t_oa_leave_apply(status, current_approver);建完之后再EXPLAINtype变成了refrows降到了几十行接口延迟从两秒多降到了三十毫秒以内。那段时间我正在优化整个OA系统的性能这条索引是效果立竿见影的一个。另外一个关于排序的优化细节。之前列表SQL里用了ORDER BY create_time DESC当分页页码比较深比如翻到第30页LIMIT 580, 20这种写法MySQL还是要先扫出前600条再丢弃前580条页码越深越慢。但我观察了一段时间审批人基本不会翻超过5页所以这个优化暂时没做。真到了要做的那天可以考虑用游标分页也就是记录上一页最后一条的create_time用WHERE create_time #{lastCreateTime} 来取下一页。这个方案在深分页场景下的性能提升非常明显但会牺牲页码跳转的灵活性要不要做取决于产品需求。其实做请假审批这个模块最花时间的不是写SQL而是把待审批这三个字的边界反复确认清楚。它看似是一个简单查询背后藏着角色权限、状态机、流程节点的三层设计。SQL本身可能只需要十分钟但前面那些设计思考决定了这十分钟写出来的东西是能撑两年还是上线两周就返工。我个人这部分做完之后的体会是只要把status和current_approver这两个字段的语义定义清楚再配合一个简单的联合索引待审批列表这块基本就稳了。后面如果再扩展多级审批、加签转派无非是再引入一张审批任务表查询条件从直接比对字段改成关联任务表思路是同一个。这个模块做完接手的同事都说这块逻辑好懂我觉得这就是最大的认可了。