简介:这份资源面向计算机相关专业学生、课程设计开发者及需要快速搭建请假审批流程的开发者,提供一套请假管理系统的完整实现素材,涵盖源码、原型与数据库三部分,适合作为毕业设计、课程作业或小型企业办公自动化的参考方案。压缩包共4个文件,包含2个zip源码工程、1个sql数据库脚本和1份pdf说明文档,整体约14.28MB,结构紧凑,便于直接解压查阅与二次开发。资源围绕请假申请、审批流转与记录管理展开,数据库脚本可用于建表与初始化数据,原型文档有助于理解页面交互与功能模块划分,源码工程则提供可运行的功能实现,方便对照学习业务逻辑与代码组织方式。目前已有1036人学习下载,说明其在同类课程设计资源中具有一定参考价值,适合需要快速上手请假管理系统开发或进行功能扩展的读者。
1. 请假管理系统:从源码到数据库,一套能跑通的原型到底长什么样
很多团队做内部工具时,第一反应是“上个低代码平台拖一拖就完了”,结果流程一复杂、审批链一变动,拖出来的表单就成了没人敢改的黑匣子。请假管理系统就是最典型的例子:表面看只是“填单—审批—销假”,真落地时却牵扯到余额扣减、审批链动态路由、并发请假冲突、历史记录追溯。我见过太多原型在演示时行云流水,一上真实数据就翻车——张三同一天被两个部门同时批了假,年假余额扣成负数。
这套“源码+原型+数据库”的组合,目标不是做一个玩具 Demo,而是给中小团队一套能直接改、能扛住几十人日常使用的底子。它适合两类人:一类是刚接手内部 OA 模块的后端开发者,需要一份结构清晰、表设计经得起推敲的参考实现;另一类是想理解“审批流+资源扣减”这类通用模型的产品或全栈,拿请假当切口,把状态机和事务边界一次搞明白。下面我会按“先立模型、再落库、后写接口、最后排坑”的顺序,把每个环节的参数和边界讲透。
2. 先把领域模型立住:请假单、审批链与余额账户怎么拆
2.1 三个核心实体与它们的关系
请假管理系统的领域模型,绕不开三个东西:请假单(LeaveRequest)、审批记录(ApprovalRecord)、假期余额账户(LeaveBalance)。很多人一上来就建一张大宽表,把申请人、审批人、天数、状态全塞进去,结果审批链一长就出现数据冗余,改一个审批人要把整行重写。
我一般会拆成三张主表加一张流水表。请假单只存“这次请假本身”的静态信息:谁请、什么类型、起止时间、总天数、当前状态。审批记录单独一张表,一条审批动作一行,记录审批人、动作(同意/驳回/转交)、意见、时间。余额账户按“人+假期类型+年度”维度建,存总额、已用、冻结中。流水表则记录每一次余额变动,方便对账。
这样拆的好处是:审批链可以无限延长而不动主表;余额扣减有据可查;状态流转只改请假单的一个字段。代价是查询时要多几次 join,但对几十人规模的系统完全不是问题。
2.2 状态机设计:为什么“审批中”要拆成两个状态
新手最容易犯的错,是把状态简单设成“待审批/已通过/已驳回”。真实场景里,一张请假单提交后可能先经过直属主管,再经过部门负责人,最后 HR 备案。如果只有一个“审批中”,你根本不知道当前卡在谁那里。
我的做法是把状态拆细:DRAFT(草稿)、PENDING_LEADER(待主管审批)、PENDING_HR(待 HR 审批)、APPROVED(已通过)、REJECTED(已驳回)、CANCELLED(已撤销)。每个状态对应一个“当前处理人角色”,审批动作触发状态迁移。这样前端能精确显示“等待谁处理”,后端也能用状态做权限校验——不是当前处理人,调审批接口直接拒绝。
状态迁移必须集中管理,不能散落在各个 service 里。我通常写一个can_transit(from_state, action)的纯函数,把所有合法迁移列成表,任何非法跳转在入口就拦掉。这个函数是后面所有接口的安全底座。
2.3 余额扣减的时机:提交时冻结,通过时扣减
余额什么时候扣,是个容易埋雷的点。如果提交时就扣,用户撤销后要加回来,一旦撤销逻辑有 bug,余额就乱了。如果通过时才扣,可能出现“审批期间余额被别的单子用光”的超请。
我采用两阶段:提交时把对应天数从“可用余额”挪到“冻结余额”,审批通过时冻结转已用,驳回或撤销时冻结退回可用。这样任何时刻可用 + 冻结 + 已用 = 总额,对账一目了然。实现上,余额账户表要有total_days、used_days、frozen_days三个字段,扣减操作全部走带条件的 UPDATE,靠数据库行锁保证并发安全。
3. 数据库表设计与建表脚本:字段、索引和约束一个都不能少
3.1 四张核心表的字段清单
先看表结构,这是后面所有代码的地基。我用 MySQL 8 的语法,其他数据库改改类型即可。
-- 请假单主表 CREATE TABLE leave_request ( id BIGINT PRIMARY KEY AUTO_INCREMENT, applicant_id BIGINT NOT NULL COMMENT '申请人ID', leave_type VARCHAR(16) NOT NULL COMMENT 'ANNUAL/SICK/PERSONAL', start_time DATETIME NOT NULL, end_time DATETIME NOT NULL, total_days DECIMAL(4,1) NOT NULL COMMENT '支持半天,用0.5步进', reason VARCHAR(255) NOT NULL, status VARCHAR(24) NOT NULL DEFAULT 'DRAFT', current_role VARCHAR(24) NULL COMMENT '当前处理人角色', created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, INDEX idx_applicant_status (applicant_id, status), INDEX idx_status_role (status, current_role) ) COMMENT '请假单'; -- 审批记录表 CREATE TABLE approval_record ( id BIGINT PRIMARY KEY AUTO_INCREMENT, request_id BIGINT NOT NULL, approver_id BIGINT NOT NULL, action VARCHAR(16) NOT NULL COMMENT 'APPROVE/REJECT/TRANSFER', comment VARCHAR(255) NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, INDEX idx_request (request_id) ) COMMENT '审批流水'; -- 假期余额账户 CREATE TABLE leave_balance ( id BIGINT PRIMARY KEY AUTO_INCREMENT, user_id BIGINT NOT NULL, leave_type VARCHAR(16) NOT NULL, year SMALLINT NOT NULL, total_days DECIMAL(5,1) NOT NULL DEFAULT 0, used_days DECIMAL(5,1) NOT NULL DEFAULT 0, frozen_days DECIMAL(5,1) NOT NULL DEFAULT 0, UNIQUE KEY uk_user_type_year (user_id, leave_type, year) ) COMMENT '余额账户'; -- 余额变动流水 CREATE TABLE balance_log ( id BIGINT PRIMARY KEY AUTO_INCREMENT, balance_id BIGINT NOT NULL, request_id BIGINT NULL, change_type VARCHAR(16) NOT NULL COMMENT 'FREEZE/UNFREEZE/DEDUCT', delta DECIMAL(5,1) NOT NULL COMMENT '正负均可', created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, INDEX idx_balance (balance_id) ) COMMENT '余额流水';字段里有几个细节值得说。total_days用DECIMAL(4,1)而不是整数,因为半天假是刚需,用 0.5 步进比存“小时数”再换算更直观。status和current_role分开存,是为了让“状态”表达流程阶段,“角色”表达该谁处理,两者解耦后加审批节点不用改状态枚举。leave_balance上的唯一键uk_user_type_year是并发安全的基石——没有它,两个请求同时插入余额账户会造出两条记录。
3.2 索引怎么加才不拖慢写入
索引不是越多越好。请假单表上我加了两个复合索引:(applicant_id, status)服务于“我的请假列表按状态筛选”,(status, current_role)服务于“待我审批的列表”。这两个是最高频查询,必须走索引。
审批记录表只在request_id上建索引,因为查审批历史永远是按单子查。余额流水表同理,按balance_id查。不要给created_at单独建索引,除非你有按时间范围扫全表的报表需求——那种场景应该走归档表,而不是在主表上堆索引拖慢每次写入。
3.3 用 CHECK 约束兜住明显非法的数据
应用层校验再全,也挡不住有人直接连库改数据。能在数据库层拦的,就别只靠代码。
ALTER TABLE leave_request ADD CONSTRAINT chk_time_order CHECK (end_time > start_time), ADD CONSTRAINT chk_days_positive CHECK (total_days > 0); ALTER TABLE leave_balance ADD CONSTRAINT chk_balance_non_negative CHECK (used_days >= 0 AND frozen_days >= 0 AND used_days + frozen_days <= total_days);chk_balance_non_negative这条尤其关键。它保证任何时刻已用加冻结不超过总额,等于给余额扣减上了最后一道保险。就算应用层并发控制写漏了,数据库也会直接拒绝,不会出现负数余额这种脏数据。代价是每次更新余额都要检查约束,但这点开销换来的是对账时的安心。
4. 原型接口实现:提交、审批、撤销三个动作的代码骨架
4.1 提交请假:冻结余额与状态初始化
提交接口要做三件事:校验时间合法性、冻结余额、创建请假单并置为待审批。核心是余额冻结必须和请假单创建在同一个事务里。
def submit_leave(user_id, leave_type, start_time, end_time, reason): days = calc_workdays(start_time, end_time) # 扣除周末和节假日 if days <= 0: raise BizError("请假天数必须大于0") with db.transaction(): # 条件更新:只有可用余额足够才冻结成功 affected = db.execute(""" UPDATE leave_balance SET frozen_days = frozen_days + %s WHERE user_id = %s AND leave_type = %s AND year = %s AND total_days - used_days - frozen_days >= %s """, (days, user_id, leave_type, start_time.year, days)) if affected == 0: raise BizError("可用余额不足") request_id = db.insert(""" INSERT INTO leave_request (applicant_id, leave_type, start_time, end_time, total_days, reason, status, current_role) VALUES (%s, %s, %s, %s, %s, %s, 'PENDING_LEADER', 'LEADER') """, (user_id, leave_type, start_time, end_time, days, reason)) db.insert(""" INSERT INTO balance_log (balance_id, request_id, change_type, delta) SELECT id, %s, 'FREEZE', %s FROM leave_balance WHERE user_id = %s AND leave_type = %s AND year = %s """, (request_id, days, user_id, leave_type, start_time.year)) return request_id这段代码的关键在UPDATE ... WHERE ... >= %s这个条件更新。它把“检查余额”和“扣减余额”合并成一条原子语句,靠数据库行锁避免并发超请。如果先 SELECT 再判断再 UPDATE,两个请求可能同时读到足够的余额,然后都扣成功,余额就成负数了。affected == 0表示条件不满足,直接抛业务异常,事务回滚。
calc_workdays要扣除周末和法定节假日,这块建议单独维护一张节假日表,不要硬编码。半天假的处理是在起止时间上做文章——如果开始和结束是同一天且只请半天,total_days记 0.5。
4.2 审批动作:状态迁移与余额结算
审批接口接收request_id、审批人、动作。先校验当前状态和审批人角色是否匹配,再执行迁移。
TRANSITIONS = { ("PENDING_LEADER", "APPROVE"): ("PENDING_HR", "HR"), ("PENDING_LEADER", "REJECT"): ("REJECTED", None), ("PENDING_HR", "APPROVE"): ("APPROVED", None), ("PENDING_HR", "REJECT"): ("REJECTED", None), } def approve(request_id, approver_id, action, comment): req = db.query_one("SELECT * FROM leave_request WHERE id = %s FOR UPDATE", (request_id,)) if not req: raise BizError("请假单不存在") key = (req["status"], action) if key not in TRANSITIONS: raise BizError("当前状态不允许该操作") new_status, next_role = TRANSITIONS[key] with db.transaction(): db.execute(""" UPDATE leave_request SET status = %s, current_role = %s WHERE id = %s """, (new_status, next_role, request_id)) db.insert(""" INSERT INTO approval_record (request_id, approver_id, action, comment) VALUES (%s, %s, %s, %s) """, (request_id, approver_id, action, comment)) if new_status == "APPROVED": settle_balance(req, deduct=True) # 冻结转已用 elif new_status == "REJECTED": settle_balance(req, deduct=False) # 冻结退回可用SELECT ... FOR UPDATE是这里的关键。它锁住请假单行,防止两个审批人同时操作同一张单子导致状态错乱。TRANSITIONS字典把合法迁移集中定义,任何不在表里的组合直接拒绝,这就是前面说的状态机底座。
settle_balance在通过时把frozen_days减掉、used_days加上;驳回时只把frozen_days减掉。两个操作都要写balance_log,保证流水完整。
4.3 撤销与并发冲突的处理
撤销只允许在PENDING_LEADER或PENDING_HR状态下发起,且只能由申请人本人操作。撤销后余额解冻,状态置为CANCELLED。
并发冲突主要出现在两个场景:同一人同一天提交多张单子,以及审批人同时点同意和驳回。前者靠余额的条件更新拦住,后者靠FOR UPDATE行锁串行化。还有一种隐蔽情况:审批人打开页面时单子还是待审批,等他点提交时单子已被撤销。这时TRANSITIONS查不到(CANCELLED, APPROVE)这个组合,直接报错,不会产生脏数据。
5. 避坑与排查:五个真实踩过的坑
5.1 余额扣成负数:条件更新写成了先查后改
现象:压测时偶尔出现frozen_days大于total_days - used_days,余额为负。原因:早期代码先SELECT出余额,在应用层判断够不够,再UPDATE。两个并发请求都读到足够余额,都执行了扣减。解决:改成UPDATE ... WHERE total_days - used_days - frozen_days >= %s的条件更新,用affected rows判断是否成功。数据库行锁保证同一行串行执行,第二个请求条件不满足自然失败。
5.2 审批链卡死:状态迁移表漏了转交动作
现象:主管点了“转交”后,单子状态没变,但审批记录多了一条,后续没人能处理。原因:TRANSITIONS里只定义了APPROVE和REJECT,转交动作没有对应迁移,代码走到查表失败却没抛异常,静默返回了。解决:所有动作必须在迁移表里有明确定义,查不到就抛异常。转交的语义是“换一个审批人但状态不变”,实现上只更新current_role对应的处理人,不改status。
5.3 半天假算成一天:工作日计算没考虑时间精度
现象:用户请当天下午半天假,系统算出 1 天,余额多扣。原因:calc_workdays只按日期差算,没看具体时刻。下午 13:00 到 18:00 被当成一整天。解决:半天假单独判断——起止同一天且时长小于等于 4 小时记 0.5 天。更稳妥的做法是让前端明确传total_days,后端只做上限校验,避免时区和工作时间段的复杂换算。
5.4 审批记录丢失:事务边界划错了
现象:请假单状态更新成功,但审批记录表里没有对应行。原因:状态更新和记录插入写在两个独立事务里,第二个事务失败后第一个已提交。解决:状态变更、审批记录、余额结算必须在同一个事务内。任何一步失败整体回滚。判断标准是:这些操作要么全成功,要么全不影响,中间态不可接受。
5.5 列表查询慢:索引建在了低区分度字段上
现象:待审批列表随着数据增长越来越慢,几十条数据要几百毫秒。原因:只在status上建了单列索引,而status只有几个值,区分度极低,优化器干脆走全表扫描。解决:改成(status, current_role)复合索引,两个字段组合后区分度足够,查询能稳定走索引。索引字段顺序按区分度从高到低排,current_role放后面是因为它经常和status一起出现在 WHERE 里。
6. 进阶技巧:用余额流水做对账与数据修复
系统跑一段时间后,最怕的是余额和流水对不上。我习惯加一个对账脚本,定期校验leave_balance的当前值和balance_log累加值是否一致。不一致说明有代码路径绕过了流水记录,得赶紧查。
-- 对账查询:找出余额与流水不符的账户 SELECT b.id, b.user_id, b.leave_type, b.year, b.used_days, b.frozen_days, COALESCE(SUM(CASE WHEN l.change_type = 'DEDUCT' THEN l.delta ELSE 0 END), 0) AS log_used, COALESCE(SUM(CASE WHEN l.change_type = 'FREEZE' THEN l.delta WHEN l.change_type = 'UNFREEZE' THEN -l.delta ELSE 0 END), 0) AS log_frozen FROM leave_balance b LEFT JOIN balance_log l ON l.balance_id = b.id GROUP BY b.id HAVING b.used_days <> log_used OR b.frozen_days <> log_frozen;这条 SQL 把每个账户的已用和冻结分别按流水累加,和账户当前值比对。FREEZE记正、UNFREEZE记负,累加后就是当前应冻结数。跑出来有差异的行,就是需要人工介入的。
修复时不要直接改余额,而是补一条调整流水,让账户值和流水重新对齐。这样审计链完整,谁在什么时候因为什么原因调整过,一查便知。我一般把对账脚本挂成每日定时任务,差异超过阈值就告警。这套机制上线后,余额相关的线上问题基本在萌芽阶段就被发现了。
另一个实用技巧是给状态迁移加审计日志。每次approve调用都把入参、迁移前后的状态、操作人写进一张audit_log表。出问题时不用猜,直接按request_id捞日志,整条链路清清楚楚。这个习惯帮我省过好几次通宵排查——有日志和没日志,定位问题的速度差一个数量级。希望帮到你。
本文还有配套的精品资源,点击获取