尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

基于Java的勤务保障系统设计与实践:从数据模型到并发控制

基于Java的勤务保障系统设计与实践:从数据模型到并发控制 简介基于 Java 语言开发的勤务保障系统设计源码面向后勤保障领域开发人员与学习者提供一套完整的企业级勤务管理解决方案。整套源码共 439 个文件包含 406 个 Java 源文件、12 个 FTL 前端模板、7 个 XML 配置文件、3 个 SQL 脚本、3 个 YAML 及若干 Properties、Markdown 和 TXT 文档压缩包约 10.22MB。模块化划分覆盖用户管理、资源调度、事件处理等核心业务Java 文件承载业务逻辑FTL 模板驱动动态页面XML/YAML/Properties 实现灵活配置SQL 脚本负责数据库交互项目内部还按 eladmin-logging、eladmin-common、eladmin-system 等工程模块组织便于维护与二次开发。已有 235 人学习适合希望从完整源码中掌握勤务系统搭建、模块划分与配置管理的中高级 Java 开发者。1. 基于Java语言的勤务保障系统先想清楚边界再动手“勤务保障”在不同行业里含义差别很大机场地勤的值班计划、铁路站段的巡检派工、政务大厅的窗口排班、园区的应急值守底层需求其实一致——把人和任务在时间轴上对齐再把任务执行痕迹留全。基于Java做这套系统技术选型几乎没有悬念Spring Boot加MyBatis Plus加MySQL能覆盖八成业务剩下的复杂度集中在排班规则、任务派发、物资台账的一致性上。对有一定Java基础的人来说真正值得花时间的不是框架API而是表怎么拆、状态怎么流转、并发下怎么保证不重复派单。下面按一条可落地的路径展开每章都能直接映射到工程里的模块。2. 勤务保障系统的数据模型把值班、任务、物资、审批拆成表2.1 四类业务对象的边界划分勤务保障系统第一版设计最容易犯的错是“一张综合表装天下”。值班记录、任务工单、物资台账、审批记录这四类数据的生命周期完全不同值班记录只增不改任务工单有状态流转物资要做数量加减审批记录要挂流程节点。混在一张大表里查询条件一旦带OR索引基本失效后期做统计报表只能全表扫。我一般会在设计评审时先定对象边界值班计划管“人什么时候在哪个勤务点”任务工单管“事交给谁办”物资台账管“东西还剩多少”审批流管“变更谁批准”。四个对象独立落表用关联字段串起来而不是把状态字段塞进同一行。这个划分决定了排班、派单、物资三个模块能否并行开发也决定了后面慢SQL出现的位置。2.2 一份能直接落库的建表语句与字段取舍下面这份建表SQL是勤务保障系统常见的数据模型骨架按“人、计划、任务、物资、审批”五个维度组织。字段统一用小写下划线命名业务表统一带create_time和update_time方便排查问题时按时间倒查。-- 人员与值班计划 CREATE TABLE duty_user ( id BIGINT AUTO_INCREMENT PRIMARY KEY, user_no VARCHAR(32) NOT NULL COMMENT 工号/员工编号, real_name VARCHAR(64) NOT NULL COMMENT 姓名, unit_code VARCHAR(64) NOT NULL COMMENT 所属部门编码, is_leader TINYINT NOT NULL DEFAULT 0 COMMENT 是否负责人0否 1是, status TINYINT NOT NULL DEFAULT 1 COMMENT 账号状态1启用 0停用, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uk_user_no (user_no) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT勤务人员表; CREATE TABLE duty_plan ( id BIGINT AUTO_INCREMENT PRIMARY KEY, plan_name VARCHAR(128) NOT NULL COMMENT 排班名称例如三班倒周计划, plan_date DATE NOT NULL COMMENT 计划日期, shift_type VARCHAR(16) NOT NULL COMMENT 班次早班/中班/夜班, user_id BIGINT NOT NULL COMMENT 人员ID关联duty_user.id, duty_point VARCHAR(128) NOT NULL COMMENT 勤务点/岗位位置, remark VARCHAR(255) DEFAULT NULL, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, KEY idx_plan_date (plan_date, shift_type), KEY idx_plan_user (user_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT值班排班计划表; -- 任务工单与物资台账 CREATE TABLE duty_task ( id BIGINT AUTO_INCREMENT PRIMARY KEY, task_no VARCHAR(40) NOT NULL COMMENT 任务单号格式YYYYMMDD当日序列, task_type VARCHAR(32) NOT NULL COMMENT 任务类型巡查/值守/应急/维修, title VARCHAR(128) NOT NULL COMMENT 任务标题, content TEXT COMMENT 任务内容与要求, assigner_id BIGINT NOT NULL COMMENT 派单人ID, assignee_id BIGINT NOT NULL COMMENT 执行人ID, status TINYINT NOT NULL DEFAULT 0 COMMENT 状态机0待领取 1执行中 2待验收 3已完成 4已退回, priority TINYINT NOT NULL DEFAULT 1 COMMENT 优先级1普通 2紧急 3特急, plan_start DATETIME DEFAULT NULL COMMENT 计划开始时间, plan_end DATETIME DEFAULT NULL COMMENT 计划完成时间, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uk_task_no (task_no), KEY idx_task_status (status, priority), KEY idx_task_assignee (assignee_id, status) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT勤务任务工单表;SQL写完要逐块解释字段取舍。is_leader用TINYINT而不是VARCHAR是为了让后续权限判断直接走数值比较避免“1”和“是”这种脏数据混进库里。task_type保留字符串而不建外键关联类型表原因是一套勤务保障系统通常只有几十种任务类型用Java枚举维护比每次join字典表更快。duty_task里同时存assigner_id和assignee_id看似冗余但“我派的单”和“我的单”都能走单列索引不需要join人员表两次再过滤。duty_plan的粒度是“人×日期×班次”一行。有的系统把计划设计成“班次模板表人员排列表”两张表省存储但查询要拆两步排班预览时要临时拼数据。对多数勤务保障业务来说直接展开成行最直观数据量按200人乘365天乘3班算也才20多万行MySQL完全扛得住。把简单模型做复杂是这类系统第一版最常见的过度设计。2.3 索引设计慢查询的根源常在 join 方式duty_task的联合索引idx_task_assignee把(assignee_id, status)放在一起是为了覆盖“我的待办”这类高频查询。执行人打开工作台时的典型SQL是where assignee_id ? and status in (0,1) order by priority desc。如果只对assignee_id建单列索引MySQL会先按人筛出当天几百行再逐行回表过滤status把status放进索引后索引树里直接完成两层过滤回表次数明显减少。下面是配置索引时的通用取舍表索引用途推荐字段组合原因排班按日查询(plan_date, shift_type)排班表按日期切片班次过滤紧随其后任务待办查询(assignee_id, status)执行人工作台是最高频入口任务超时扫描(status, plan_end)定时任务扫“未完成且已过截止时间”的工单操作日志回溯(biz_type, biz_id)按业务单据查变更历史索引不是越多越好。第一版最容易出现的坑是给create_time单独建索引。排班和任务表都按时间范围查询看起来该建但联合索引最左前缀里已经有create_time时单独索引就是纯浪费。另一个高频问题出在统计语句select count(*) from duty_task where date(create_time) ?。date()函数包住索引列会让索引失效正确写法是改成create_time ? and create_time date_add(?, interval 1 day)。这个细节在MySQL 8.0里依然成立排查慢SQL时第一眼就要找函数包裹列的位置。注意给已有索引列套上函数、运算或隐式类型转换都会让优化器放弃索引范围扫描。排查顺序是先看WHERE条件里有没有函数包裹列再看联合索引的最左前缀是否被跳过。3. Java 排班引擎与任务派发勤务保障系统最容易写乱的两个模块3.1 排班生成先跑通固定轮转规则再谈算法优化勤务排班在绝大多数单位不是“遗传算法找最优解”而是循环轮转加手工微调。常见轮转规则是“三班两运转”或“上一休一”每条规则对应一个周期数组。Java实现的核心是把“周期”翻译成“日期余数”public ListDutyPlan generateRoundRobin(ListLong userIds, LocalDate start, LocalDate end) { // 轮转模式下标0是早班1是夜班2是休息 String[] pattern {EARLY, NIGHT, OFF}; int userCount userIds.size(); ListDutyPlan plans new ArrayList(); for (LocalDate day start; !day.isAfter(end); day day.plusDays(1)) { // 关键把日期转换成周期内下标保证跨月不断档 int dayIndex (int) (day.toEpochDay() % pattern.length); int userIndex (int) ((day.toEpochDay() / pattern.length) % userCount); plans.add(buildPlan(userIds.get(userIndex), day, pattern[dayIndex], 主岗)); } return plans; }这段代码的核心是两次取模。day.toEpochDay()返回从格林尼治标准时间1970年1月1日到当天的天数对pattern.length取模得到班次下标除以pattern.length再对人员数取模得到当天轮值的人。基准日是固定常量只要开始日期不变任何一天的排班结果都是确定的这就是“排班预览”和“最终排班”能对得上的原因。参数说明里最容易忽略的是边界循环条件用!day.isAfter(end)而不是day.isBefore(end)否则月末最后一天会被丢掉。生成后要做去重校验同一人同一天不能出现在两个勤务点用group by plan_date, user_id having count(*) 1一条SQL就能查出。生成排班后不直接落库先返回List给前端预览用户确认后才批量插入。批量插入用MyBatis Plus的saveBatch默认批次1000200人一个月的排班约2000行一次调用就能完成。如果一次生成一整年的排班我会把12个月分给线程池并行计算主线程用CountDownLatch等待全部完成再统一落库这比单线程循环快也比裸用线程池更可控。这里常被忽略的是重复生成排班模块要提供“覆盖式生成”还是“增量生成”的选择多数单位希望保留手工调整结果所以落库前先删除同区间已有计划删除条件用plan_date between ? and ?避免误删其他月份。3.2 任务派发一个能长期维护的执行人选择模型任务派发常见有三种入口管理员手动指派、排班自动带出、执行人主动领取。手动指派和排班带出最终都落到同一个service方法主动领取单独走并发控制。派发的核心数据只有三样任务内容、执行人、截止时间。值得注意的设计是对执行人字段的宽容处理单人多任务对勤务场景是常态表里用assignee_id表示主要执行人需要多人协作时应用层维护一张task_user_rel中间表而不是在duty_task里塞逗号分隔的id列表。派发动作里最容易犯错的是计算截止时间。任务的plan_end必须依赖优先级而不是写死“24小时内完成”。优先级与默认截止时间的映射如下优先级编码枚举含义默认截止时间典型场景1普通24小时日常巡查、例行保养2紧急6小时设备告警、临时加岗3特急1小时应急抢险、突发处置下面给出创建任务的完整方法Transactional(rollbackFor Exception.class) public Long createTask(DutyTaskVO vo) { DutyTask task new DutyTask(); task.setTaskNo(generateTaskNo()); // 每天重置的序号用Redis INCR生成 task.setTaskType(vo.getTaskType()); task.setTitle(vo.getTitle()); task.setContent(vo.getContent()); task.setAssigneeId(vo.getAssigneeId()); task.setStatus(0); // 待领取 // 优先级映射到截止时间普通24h紧急6h特急1h task.setPlanStart(LocalDateTime.now()); task.setPlanEnd(calcDeadline(vo.getPriority(), vo.getPlanEnd(), LocalDateTime.now())); dutyTaskMapper.insert(task); return task.getId(); }calcDeadline的逻辑是调用方传了planEnd就用调用方的没传且是紧急任务则当前时间加6小时。这个“显式优先、规则兜底”的设计能避免业务方反复改优先级枚举。事务注解用rollbackFor Exception.class而不是默认的RuntimeException是为了让受检异常也回滚不然邮件通知失败会导致任务单部分落库。generateTaskNo用Redis INCR配合日期前缀生成任务单号比数据库sequence更合适因为任务单号要求“当天从1开始连续”数据库自增主键没法按天重置。3.3 并发领取用条件更新拦下重复派发勤务保障系统里“多人同时抢领同一条任务”是最常见的并发场景。两个执行人同时点了领取如果代码写成先select判断status再update就有概率重复。加synchronized只能挡单机多实例部署立刻失效。可靠做法是update语句带条件直接变更状态影响行数为1才算抢到public boolean claimTask(Long taskId, Long userId) { // 状态从0(待领取)改为1(执行中)条件里带状态保证只有一个实例能成功 int rows dutyTaskMapper.claim(taskId, userId, LocalDateTime.now()); return rows 1; }对应XML里的update语句update idclaim UPDATE duty_task SET assignee_id #{userId}, status 1, begin_time #{now}, update_time #{now} WHERE id #{taskId} AND status 0 /updateupdate自带行锁两个事务同时执行时第二个会被阻塞到第一个提交然后因为status不再是0影响行数返回0领取失败。这个写法的关键约束是WHERE条件必须和状态字段绑定且状态流转只能单向推进。如果业务允许“退回后再领取”退回操作要把status置回0语义才闭合。更严格的实现可以加version乐观锁字段但对任务领取这种高频小事务条件更新已经足够再加version反而多一次update。这里其实就是Java面试八股文里常说的乐观锁思想只不过落点不在version字段而在状态条件本身。判断重复领取后前端要感知到明确错误所以service里把rows 0映射成BizException(任务已被他人领取)而不是静默返回成功。4. 审批状态机与物资出入库把勤务保障闭环串起来的两个细节4.1 审批引擎用枚举加状态表收敛流转分支勤务任务被退回、物资申请要审核都涉及审批流。多单位共用一套系统时最大的痛点是每个单位审批层级不同有的三级有的一级。第一版最稳妥的状态机是“提交到审批中再通过或驳回”层级留给流程配置表而不是写死在Java分支里。这里用枚举收敛动作public enum ApproveStatus { DRAFT(0, 草稿), PENDING(10, 待审批), APPROVED(20, 已通过), REJECTED(30, 已驳回); private final int code; private final String desc; // 状态可达性矩阵从当前状态能否执行某动作 private static final SetString ALLOWED_TRANSITIONS Set.of( DRAFT-SUBMIT, PENDING-APPROVE, PENDING-REJECT); public boolean canTransition(String action) { return ALLOWED_TRANSITIONS.contains(this.name() - action); } }审批记录的落库表结构要能回答“这条记录现在到哪一层”approval_id、biz_type、biz_id、current_node、approver_id、action、comment、create_time。查询某张单子的审批历史时按biz_type和biz_id查询并按create_time排序。这里的状态机语义可以用下面的动作表说明动作前置状态后置状态说明SUBMITDRAFTPENDING提交申请进入审批队列APPROVEPENDINGAPPROVED当前审批人通过REJECTPENDINGREJECTED退回申请允许修改后重新提交这套设计的取舍在于不做万能工作流引擎省掉流程引擎的部署和维护成本换来的是代码可读性。只有当出现“会签”“或签”“条件跳转”这些明确需求时才引入Flowable而勤务保障系统第一版基本遇不到这些场景。4.2 物资出入库数量一致性用事务加条件更新保证物资保障模块的经典场景是“领取一套防护装备”库存表减一领取记录加一行。这两个动作必须在同一事务里否则库存扣了记录没写或者记录写了库存没扣台账就对不上。库存更新推荐用条件更新而不是先查后改Transactional(rollbackFor Exception.class) public void issueMaterial(Long materialId, Long userId, int quantity) { // 条件更新stock quantity 才扣减避免超发 int rows materialStockMapper.deduct(materialId, quantity, LocalDateTime.now()); if (rows 0) { throw new BizException(库存不足或物资不存在); } MaterialRecord record buildRecord(materialId, userId, quantity); materialRecordMapper.insert(record); }deduct的SQL是update material_stock set stock stock - #{quantity} where id #{id} and stock #{quantity}。为什么不用select查库存再update因为两个执行人同时领取最后一件物资时查到的都是剩余1各自判断够用先后update就会把库存扣成负数。把stock #{quantity}放进条件里一行update只能成功一次另一个执行人拿0行直接走异常分支。物资台账记录表只做追加不做更新和删除这样库存流水始终可以追溯。真要纠错时写一条负数的冲正记录而不是delete原记录这是审计上最干净的做法。物资记录的入库出库方向建议用独立字段表达而不是靠数量正负推断见下表record_type方向库存影响典型场景ISSUE出库扣减领用装备、耗材RETURN入库增加归还装备、退料ADJUST调整增或减盘点差异修正、报损4.3 事务边界一个请求只该有一个事务审批和物资两个模块都用了Transactional但如果把不相关的业务塞进同一个事务方法问题就来了。比如任务派发成功后要发送站内信、推送消息给执行人这些动作放事务里会拉长数据库连接持有时间高并发时连接池先被打爆。常见做法是事务内只操作核心表事务提交后通过Spring的事件机制发消息。Java里可以用TransactionalEventListener监听事务提交事件在afterCommit阶段发通知保证“要么业务都成功要么通知都不发”。一个现实的边界案例是导入排班Excel导入5000行数据时逐行insert会非常慢正确做法是先把Excel解析成内存中的List校验通过后再批量插入。校验失败时整个导入事务回滚内存列表自然丢弃不会留下半批数据。这里的关键参数是saveBatch的batchSizeMyBatis Plus默认1000导入5000行时应分批次提交避免单条SQL过长。如果数据量超出事务内存容量就按每500行手动flush不能指望一条insert语句带5000组值。读MyBatis源码时能看到Executor的批处理逻辑batch模式下SQL和参数会攒到一定数量才统一刷新理解这个机制后批量插入的参数设置就不再是拍脑袋。5. 勤务保障系统上线前最值得做的收敛慢SQL定位与操作留痕5.1 用慢查询日志反推索引缺口勤务保障系统上线前最好先模拟高峰期的查询。打开MySQL慢查询日志阈值设1秒跑一遍排班预览、任务列表、物资台账这些高频页面日志里出现的每一条SQL都要拿EXPLAIN看type字段。以下是一组常用的排查命令# 打开慢查询日志阈值1秒没用的索引会在日志里现出原形 mysql -uroot -p -e set global slow_query_logON; \ set global long_query_time1; set global slow_query_log_file/tmp/slow.log; # 取日志里最耗时的20条按执行时间倒序 mysqldumpslow -s t -t 20 /tmp/slow.log验证索引是否生效重点看EXPLAIN输出里的type和key两列。type为ALL说明全表扫描回到2.3的联合索引设计去补索引type为ref但key很短时要检查是不是只命中了联合索引的最左前缀。还有一个容易被忽略的指标是rowsEXPLAIN里预估扫描行数比实际行数高一个数量级时通常是统计信息过期执行ANALYZE TABLE duty_task刷新即可。5.2 操作留痕用独立的日志表勤务数据有很强的审计需求谁改过排班、谁退回过任务、谁动过库存都要留下痕迹。第一版最常见的错误是把审计字段塞进业务表比如给duty_plan加last_edit_by、last_edit_time。这种做法只能记最后一次修改中间过程全部丢失。正确做法是建独立的operation_log表字段为biz_type、biz_id、action、operator_id、before_json、after_json、create_time。Java端通过AOP注解记录变更切点在service层方法上before和after两个快照用Jackson序列化成JSON存进去。字段里before_json和after_json是排查纠纷的底牌。修改排班时before是原班次after是新班次争议发生时直接对比JSON差异即可确认操作过程。这个表的写入要放到事务外层用TransactionalEventListener的afterCommit触发避免日志写入失败导致业务回滚。AOP切点时注意过滤查询方法只拦截写操作避免日志表被查询请求刷爆。本文还有配套的精品资源点击获取
返回列表