
接手这个项目的时候客户给的需求其实就八个字把排班这事从人肉Excel里解放出来。但真动手拆解才发现所谓“基层智能化人员调度”背后牵扯的是人员档案、班次规则、临时换班、任务派发、考勤统计、消息触达一整条链路。这篇博文我就把这个基于SpringBoot Vue的落地过程完整捋一遍从数据库设计到后端核心逻辑再到前端界面怎么跟调度流程咬合最后把打包部署和排坑经验一并交代清楚给正在做同类业务系统的同学一个可参考的底稿。1. 项目定位基层调度到底在解决什么痛点先说实话基层调度这种系统不像电商中台那么花哨它的核心价值就是一个词少扯皮。没系统之前调度员手里攥着一张排班表排班靠经验换班靠微信群接龙统计考勤靠月底翻聊天记录。这个系统要做的就是把“经验”变成“规则”把“接龙”变成“流程审批”把“翻记录”变成“一键导表”。我梳理了一下整个需求范围功能边界大概落在六块人员档案管理基层人员的工号、姓名、班组、岗位、技能标签、联系方式、账号状态。班次规则配置早晚班、中班、夜班、机动班、休息日的定义以及班次之间最小间隔约束。智能排班引擎基于规则自动生成月度排班表支持手工微调。任务临时调度突发支援、顶岗、加班、临时任务派发生成调度记录。排班结果确认与通知生成结果后按班组推送支持个人确认、换班申请、审批。考勤与统计看板出勤天数、加班时长、调休结余、班次覆盖率的查询与导出。技术栈选型上后端SpringBoot MyBatis-Plus MySQL前端Vue3 Element Plus Pinia这套组合在中小企业内部的业务系统里属于绝对的主流配置。原因其实很务实招聘成本低社区文档多出了问题随便一搜就有解决方案不需要像某些冷门框架那样自己对着官方文档猜。1.1 从业务场景反推技术需求调度系统有个跟普通CRUD后台不太一样的点就是它同时存在周期性规律和突发性变化。周期性规律对应月度排班属于计划型工作流突发性变化对应临时顶岗、紧急支援属于事件型工作流。这两种流程必须同时被系统覆盖否则又会回到Excel补丁的套路。另外一个容易被忽略的点是权限粒度。基层系统的用户角色往往不像OA那样一刀切而是存在“调度员看全部、班组长看本组、普通员工看个人”的多层级数据隔离。所以后端在做权限设计时不能只做菜单级别的拦截行级别的数据范围控制才是核心难点。后面我在讲后端实现时会详细展开这套思路。1.2 这个项目适合谁参考如果你是初级后端开发正在找SpringBoot Vue 的全栈演练项目这篇博客里的权限设计、排班算法、WebSocket通知是你的重点如果你是企业内部IT准备给后勤、物业、运维这类基层班组做调度工具那数据库设计和排班规则的建模方式可以直接搬如果你纯粹想看看这类系统怎么从需求拆解到落地那整个架构演进的过程应该能给你一个大致的全局观。2. 技术选型与项目骨架搭建为什么是SpringBoot Vue市面上做管理系统的方案其实很多Python的Django、C#的.NET Core、Node的NestJS都能做。但SpringBoot在这类业务里有一个别人比不了的优势生态太成熟了。权限有Spring Security缓存有Redis Starter定时任务有Scheduled消息推送有WebSocket原生支持几乎每一样基层调度系统需要的能力都有开箱即用的组件不需要自己造轮子。前端选Vue3而不是React我的理由是Vue的模板语法对后端转前端的人来说更友好而且Element Plus这个组件库直接提供了表格、表单、日历、日期选择器、树形控件这些后台管理系统的高频组件开发效率会比从零写组件快非常多。再加上Vite的启动速度调试体验比老一代的webpack项目舒服得多。2.1 项目分层结构与模块划分实际搭建的时候我没有按传统的controller/service/mapper三层一刀切而是按业务域做了包结构划分。这样做的直接好处是后续加功能时不需要在几百个文件里找对应逻辑。com.company.dispatch ├── common // 通用工具、异常处理、常量 │ ├── exception │ ├── result │ └── utils ├── config // 全局配置Security、WebSocket、Redis ├── modules │ ├── auth // 登录认证 │ ├── personnel // 人员档案 │ ├── schedule // 排班引擎 │ ├── task // 临时任务调度 │ ├── attendance // 考勤统计 │ └── notify // 消息通知 └── framework // 数据权限、基类封装前端这边的目录也按模块对齐src ├── api // 接口请求封装 ├── router // 动态路由 ├── store // Pinia状态管理 ├── views │ ├── dashboard │ ├── personnel │ ├── schedule │ ├── task │ ├── attendance │ └── system ├── components // 公共组件 └── utils前后端模块名保持一致性在联调的时候会省去大量“这个接口归属哪个模块”的沟通成本。后端出一个排班接口前端同事直接去schedule目录下找对应页面几乎零沟通成本。2.2 环境版本与依赖选型这次项目的版本组合我踩过一次坑所以列出来给各位避雷参考组件版本说明JDK1.8生产环境稳定spring boot 2.x完全兼容SpringBoot2.7.x不要直接用3.x部分二方包没跟上MyBatis-Plus3.5.x真香代码生成器太重可以用手动映射MySQL5.78.0也行需要改驱动配置Redis6.x做缓存与在线状态Vue3.4.x配套Element PlusNode.js18低于16的vite跑不起来Element Plus2.xUI库Maven3.8构建工具项目创建这块我多说一句很多人习惯用IDEA的Spring Initializr直接生成但那个生成的版本往往默认到SpringBoot 3.x。如果你在的公司内部还有老依赖直接选3.x会出现各种兼容性问题。我这次是从SpringBoot 2.7.14手动搭建的虽然慢一点但所有依赖都在掌握之中。Maven的pom.xml里核心依赖就不贴全代码了只强调两个容易踩坑的点MyBatis-Plus分页插件要单独配置拦截器否则分页失效Redis的序列化器必须改成Jackson否则存取对象时会默认走JDK序列化缓存里全是乱码。3. 数据库设计调度系统的表结构与核心字段逻辑数据库是整个调度系统的地基这一块设计得不好后面写代码会非常难受。基层人员调度系统相比普通OA系统它的核心表其实不算多但每张表的字段都需要仔细斟酌。3.1 核心数据表全景我把整个系统的表分成三组基础档案组、调度业务组、流程支撑组。基础档案组sys_user用户账号表字段比较多包括user_id、username、password、real_name、phone、avatar、status、dept_id、post_id这里的dept_id关联部门表用来做数据权限范围判断。sys_dept部门/班组表包括dept_id、parent_id、ancestors、dept_name、order_num、leader注意基层系统里班组往往是树形层级会有“服务中心下面分设一班、二班”的情况所以parent_id和ancestors字段要保留用祖先串可以快速做子集查询。sys_post岗位表基层的岗位其实不多但像“维修工”、“巡检员”、“值机员”这种岗位直接决定了排班时的人员匹配所以post表里有一个字段叫skill_tags用逗号分隔技能标签临时调度时会拿这个标签做匹配。调度业务组sch_shift班次表这是排班的地基。关键字段有shift_id、shift_name、start_time、end_time、shift_type。shift_type很关键我定义的是枚举值1-白班 2-夜班 3-中班 4-机动 5-休息。设计时有个小技巧夜班跨天问题比如夜班从22:00到次日06:00如果用日期时间的字符串方式存排班时跨天判断会非常痛苦。这里我单独加了is_cross_day字段夜班为1同时存的是开始分钟数和结束分钟数00:00到23:59折算成0到1439跨天判断就变成结束分钟数小于开始分钟数即为跨天。这个字段在后面写排班冲突检测时帮我省了至少半天时间。sch_schedule排班表关键字段是schedule_id、user_id、shift_id、work_date、approve_status、confirm_status。work_date是date类型不含时分秒approve_status标记这条排班记录是否经过审批confirm_status标记员工是否确认接收。ts_task临时任务表关键字段是task_id、title、content、task_type、need_person_count、assign_type、priority、start_time、end_time、status。assign_type区分是指定人员还是按技能标签自动匹配status用0-待派发、1-已派发、2-已接受、3-已完成、4-已取消这是一个简化的状态机。ts_task_user任务人员关联表这是多对多关系的中间表task_id user_id accept_time complete_time用来记录谁接受了哪个临时任务。流程支撑组sch_change_apply换班申请表原值班人submitted_by、承接人take_over_by、原值班日期work_date、状态approve_status这是基层微信群接龙场景的线上化。notify_message消息通知表message_id、title、content、receiver_id、message_type、is_read、create_time。设计时注意查收效率每次给100个人推送排班结果时如果用关联表存接收人会多一张映射表查询时反而慢。我这里直接在一条消息里存receiver_group字段1-全体 2-指定部门 3-指定人员和receiver_ids字段逗号分隔ID读的时候用FIND_IN_SET(user_id, receiver_ids)来过滤实测性能完全够用。3.2 排班约束怎么落到数据层排班系统最好玩的部分其实在约束。比如“一个员工一天最多一个班次”、“连续夜班不能超过3个”、“换班后同一个人不能同时出现两个班次”这些规则如果全写在业务代码里排班引擎会变成一坨屎山。我的做法是把能枚举的约束拆成排班规则表sch_rule ├── rule_id ├── rule_name ├── rule_code ├── rule_params // JSON格式的参数比如禁连天数上限 └── status排班引擎在生成排班时先读取所有启用的规则逐条对候选组合做校验。这样一来加一个“周五下午不安排培训人员值班”这种规则时只需往规则表插一条记录再实现一个校验器而不会影响整体引擎逻辑。这个设计思路借鉴了规则引擎的轻量级实现方式不用引入Drools那么重的东西但对基层系统的灵活性需求来说刚好够用。3.3 数据库索引设计实战调度系统最频繁的查询是“查某人某段时间的排班记录”所以索引设计主要围绕这个场景idx_user_date (user_id, work_date) idx_date_shift (work_date, shift_id) idx_task_status (status, need_person_count) idx_notify_receiver (message_type, receiver_id)特别注意不要在work_date上单独建索引再用函数处理。比如你想查“3月份的排班”如果索引建的是work_date单列你用WHERE MONTH(work_date)3是走不了索引的。正确做法是查区段WHERE work_date BETWEEN 2025-03-01 AND 2025-03-31很多开发在写查询时会忽略这点导致数据量上来后接口慢得离谱。4. 后端核心模块实操权限、排班算法与消息推送后端是整个系统的“发动机”SpringBoot框架本身已经把大部分底层工作解决了真正需要自己动手的核心逻辑集中在三个地方认证与权限、排班算法、消息通知。4.1 JWT认证与数据权限范围控制认证选型上没有纠结直接用JWT Spring Security。逻辑流程用户登录校验账号密码和状态签发JWT token。Token携带用户ID、部门ID、角色编码有效期设为8小时。前端每次请求在header里带Authorization: Bearer token。后端写一个JWT过滤器解析token并set到SecurityContextHolder里供后续业务方法获取当前用户。写这块时我踩了一个典型的坑跨域配置与过滤器的顺序问题。如果前端用Vite做代理后端起了一个服务就不存在跨域问题。但如果前端打包后放在同一个SpringBoot服务的static目录下就走同源了。我的建议很简单开发环境用Vite代理生产环境把前端构建产物放进SpringBoot的static目录实现同源部署彻底规避跨域后面打包章节会细讲。数据权限这块是基层调度系统独有的难点。一个调度员登录后他应该能看到所有班组的数据一个班组长登录后只能看到自己部门的数据一个普通员工登录后只能看到自己的排班与任务。实现方案我参考了若依框架的做法public class DataPermissionHelper { public static void checkDataScope(DeptQuery query, LoginUser user) { if (user.isAdmin()) { // 管理员不限制 return; } String roleCode user.getRoleCode(); if (DISPATCHER.equals(roleCode)) { // 调度员全部数据 query.setDeptScope(null); } else if (LEADER.equals(roleCode)) { // 班组长本部门及以下 query.setDeptScope(user.dept_id IN (SELECT dept_id FROM sys_dept WHERE ancestors LIKE CONCAT(%, user.getDeptId() , %))); } else { // 普通员工仅本人 query.setDeptScope(user_id user.getUserId()); } } }这段代码的SQL片段采取拼接方式初看有点粗暴但本质上这个场景里拼接的值是后端从token解析出来的不涉及用户输入所以SQL注入风险可控。如果要更规范的方案可以换成MyBatis-Plus的DataPermissionInterceptor但那样会多不少配置。小团队做内部系统实用优先。4.2 智能排班引擎的设计与实现排班引擎是我写这个系统时最有意思的部分。说白了它就是在满足各种约束条件下给一组人和一组班次做匹配。业务规模不大的话不需要上算法库用贪心策略就能解决大部分问题还容易理解。我实现的思路大致是这样月底拉取下个月的班次模板比如“工作日每天需要2个白班2个夜班周末1个白班1个夜班”。初始化每个员工的剩余可用天数排除年假、调休等。逐天排班对当天的班次空缺从候选池里按优先级挑人。优先级规则按得分计算。候选人的得分计算我做成一个评分函数score 上次值班距今的天数 * 2 - 本月已值班天数 - 连续值班超过3天的惩罚分(10分) 是否含所需技能标签的奖励分(5分) - 昨天已经安排夜班的惩罚分(8分)得分最高且满足硬性约束如一天只能一班、班次间隔不小于8小时的人被选上。整个算法就是100多行代码足够应付20人以上班组的调度场景。有一件事我必须强调完全自动排班是会出问题的。比如排出来发现某人连续上了5天夜班虽然满足规则但如果有两个人年龄偏大老排到夜班体验就非常差。所以我的最终方案是“自动生成 人工微调”引擎先跑一轮生成初始排班。保存为“草稿态”调度员在排班管理页面上可以直接拖拽调换班次。点击发布后写进正式排班表并触发确认通知。员工在手机端或PC端确认排班调度员能看到确认进度。这样一来系统从“替代人”变成了“辅助人”实际交付投用后的抵触情绪小了很多。4.3 WebSocket实时推送与消息通知调度系统有个硬需求就是时效性。临时任务一发布相关的人员最好在几秒内就能收到消息而不是等下次刷新页面才发现。这里我用了SpringBoot原生WebSocket配合自定义Session管理。实现要点建立一个WebSocket端点/ws/notify前端连接时带上token参数。后端维护一个ConcurrentHashMapLong, Session保存userId到Session的映射。发布任务或排班后遍历相关用户ID向对应Session推送JSON消息。推送消息的结构{ type: TASK_ASSIGN, title: 临时任务西门岗支援, content: 今晚20:00-22:00需要2人到西门岗支援请尽快确认。, timestamp: 1720000000000 }消息推送给用户后用户点击确认前端调一个确认接口后台再更新任务状态并给调度员推送一条“已确认”之类的反馈。这里在开发WebSocket时要特别注意一个问题WebSocket的Session是单向的服务端推给客户端没问题但要拿集群时Session同步是个坑跨实例推送需要引入Redis发布订阅。这个项目的部署规模是单机我就没有做集群方案但如果你的系统要上两台服务器记得提前规划Session同步否则用户会莫名其妙地收不到某些节点的消息。5. 前端核心模块实操动态路由、排班日历与状态管理前端这一侧除了常规的登录页和主页框架我重点想分享三个部分动态路由怎么根据角色生成、排班日历怎么做到好用、数据状态如何用Pinia统一管理。5.1 动态路由与菜单权限控制的落地后台管理系统的路由权限如果不做动态控制就会出现“普通员工把管理员菜单看光”的尴尬情况。经典做法是登录成功后后端返回当前用户角色的菜单列表前端用router.addRoute动态注册。这里我踩过一个隐藏的坑刷新页面时动态路由会丢失。因为Vue Router的路由表是内存态的刷新之后整个store重新初始化路由只剩静态部分动态注册的全没了如果用户当前正在一个业务页面刷新后会直接白屏或跳404。解决办法在路由的beforeEach守卫里判断当前用户信息有没有存在于store中。如果没有从LocalStorage里取登录信息并请求“获取用户信息菜单列表”接口。拿到菜单后动态addRoute再放行当前路径。如果路径仍然没有匹配上再跳404。实现时有个小技巧动态路由组件要用() import(/* vite-ignore */../views/${componentPath}.vue)这种异步引入方式配合Vite的Glob API不然打包时无法生成对应chunk运行时会报“Failed to resolve component”的错误。5.2 排班日历组件的设计与交互细节排班日历是整个前端最核心的交互模块。我调研过element-plus自带的日历组件它提供基础渲染能力但做高定制的排班视图还是得二次封装。我自定义的实现思路是这样上个月/下个月切换用日期计算函数获取当前月的第一天和最后一天以及起始周的周一日期。把30天或31天格式化成6x7的二维数组每天的格子渲染当天的排班数据。每个格子下方按班次类型列出人选颜色区分夜班用深蓝色标签、白班用浅黄色标签、休息用灰色“休”字、临时任务用红色边框。点击某天格子弹出当天详细排班列表点击某个人可以弹出个人排班卡片显示本月出勤统计。这里有个提升体验的细节拖拽调班。值班表手动调整频率其实很高拖拽比下拉选择快得多。我是用HTML5原生拖拽实现的——把一个员工标签拖到另一天的格子里触发一个确认弹窗调用换班接口。写拖拽时有一个兼容性坑draggable属性在某些浏览器上对中文文本的拖动会触发默认行为必须在dragstart事件里调用event.dataTransfer.setData(text/plain, ...)否则拖过去啥也带不过去。5.3 Pinia状态管理与接口请求封装状态管理我选了Pinia而不是Vuex主要原因是Pinia对TypeScript支持更好、不需要写mutation让我少写一半样板代码而且Vue3官方也已经把它当成推荐方案了。我的store拆成三个核心模块user.js存token、用户信息、角色、部门。schedule.js存当前选择的排班月份、排班数据、筛选条件。notification.js存未读消息列表、WebSocket连接状态。这里有个经验供参考排班数据不要全部塞到store里。有的人贪方便把一整个月的排班都塞进state结果页面切换和搜索时状态非常混乱。我的做法是store里只存筛选条件和当前选中的日期真正的排班数据在组件内部通过API查询。只有需要跨组件共享的数据才放进store比如当前月份的值排班页和统计页都要用。接口请求封装我统一用axios实例// api/request.js import axios from axios import { useUserStore } from /store/user import { ElMessage } from element-plus const service axios.create({ baseURL: import.meta.env.VITE_API_URL || /api, timeout: 15000 }) service.interceptors.request.use(config { const userStore useUserStore() if (userStore.token) { config.headers[Authorization] Bearer userStore.token } return config }) service.interceptors.response.use( response response.data, error { // 处理401token过期跳转登录 if (error.response error.response.status 401) { const userStore useUserStore() userStore.logout() router.push(/login) } ElMessage.error(error.response?.data?.message || 请求失败) return Promise.reject(error) } )注意一个细节开发环境用Vite代理需要配server.proxy。生产环境前端打包后放到SpringBoot的static目录请求相对路径直接走同源这就回到了上一节说的跨域问题用同源部署直接一劳永逸。6. 部署与打包Vue如何放进SpringBoot里一键运行很多同学单独做前端项目时会打包但前后端分开部署需要两个地址对内网小团队来说维护成本有点高。这个项目采用的生产部署方式是前端构建产物直接放进SpringBoot的static目录打成一个大jar包一键启动所有页面和接口都在同一个端口。具体流程前端执行npm run build生成dist目录。将dist目录下的所有文件复制到SpringBoot项目的src/main/resources/static/下。重新执行Maven打包mvn clean package -DskipTests。生成一个x.jar文件java -jar x.jar启动。启动后访问http://localhost:8080/就能直接打开登录页接口访问/api/xxx也走同端口。这个方案是我比较推荐的它适合内部系统这种用户量不大、访问方式单一的场景省掉了Nginx的配置步骤。6.1 Maven构建与前端打包的整合方式命令说起来简单实际操作中有几个细节必须注意前端路由用history模式时刷新会404。Vue Router如果把路由模式设为history打包后放到SpringBoot的static里你访问http://ip:8080/login是没问题的因为这是前端路由跳过去的但你直接用浏览器地址栏访问http://ip:8080/login或者在登录页按F5刷新后端会去找一个不存在的/login路径直接404。解决方案有两种一种是路由改成hash模式URL会带一个#丑但不用改后端另一种是在SpringBoot里写一个转发Controller把所有非API路径都转发到index.html。我的建议是第二种代码就几行但体验好很多Controller public class ForwardController { RequestMapping(value {/, /login, /dashboard, /schedule/**, /personnel/**}, method RequestMethod.GET) public String forward() { return forward:/index.html; } }这个写法的原理是前端所有业务路径除了带.的资源路径都转发到首页HTML再由Vue Router接管路由。唯一的注意点是你要确保静态资源js/css文件名里带hash不然缓存更新会有问题。前后端联调时的代理配置。开发时前端跑在5173端口后端跑在8080端口跨域问题用Vite代理解决// vite.config.js server: { proxy: { /api: { target: http://localhost:8080, changeOrigin: true }, /ws: { target: ws://localhost:8080, ws: true } } }WebSocket的代理一定要单独配而且必须ws: true否则WebSocket连接会失败。我在开发时因为没有配这部分前前后后排查了一个多小时最后发现是代理配置缺了ws: true改完秒连。6.2 服务器部署环境与参数调优生产服务器的环境我是基于Linux JDK8部署的没有额外装MySQL和Redis在同一台机器上配置大概是2核4G。这种配置下SpringBoot默认配置直接跑其实会有一些隐患我调整了几个关键参数spring: datasource: url: jdbc:mysql://localhost:3306/dispatch?useUnicodetruecharacterEncodingutf8useSSLfalseserverTimezoneAsia/Shanghai hikari: maximum-pool-size: 10 minimum-idle: 5 connection-timeout: 30000 redis: host: localhost port: 6379 timeout: 5000 server: port: 8080 servlet: context-path: / tomcat: max-threads: 200 min-spare-threads: 20 max-connections: 500maximum-pool-size设为10不是乱拍的。这里有个经验公式并发线程数 单线程QPS × 数据库操作耗时秒× 目标并发。内部系统同时在线人数撑死100人实际并发操作数据库的请求不超过20个连接池设10已经非常宽裕。如果调得过高反而会白白消耗MySQL的连接数拖垮数据库。另外注意serverTimezone必须设成Asia/Shanghai否则当你用MySQL 8驱动连本地数据库时时区不对会导致日期字段差8小时排班界面出现“排班排到了前一天”的诡异bug。7. 常见问题与排坑实录这部分我整理写代码、测试和上线后被问得最多、也最容易踩的几个问题。每一条都是我实际遇到过的放到这里相当于一个速查表。7.1 典型运行异常速查表症状可能原因解决思路登录后接口提示401JWT过滤器没放行/loginSecurityConfig里anonymous放行登录接口前端页面刷新404路由history模式 无转发加ForwardController或改hash模式排班分页数据不返回MyBatis-Plus分页拦截器未配置新建MybatisPlusConfig注入PaginationInnerInterceptor排班日期显示为前一天数据库连接时区未设置jdbcUrl加serverTimezoneAsia/ShanghaiWebSocket连不上Vite代理缺ws:true代理配置加ws: true后端CORS报错前端直连后端而非代理开发用Vite代理生产用同源部署Redis存对象报乱码没有配Jackson序列化器自定义RedisTemplate的value序列化器按钮点了没反应Vue组件事件名写错驼峰vs短横线用clickhandleClick而非clickhandle-click7.2 业务逻辑层的三个教训第一个教训是换班审批的事务边界。这个系统里换班申请通过后要同时更新两张排班记录原值班人的记录被清空承接人的记录被新增。如果这中间不放到同一个事务里一旦第二步失败就会出现双方都空岗的严重bug。写完后端接口一定要确认Transactional有没有加到service层的public方法上同时注意事务的粒度要细审批逻辑只包住两条更新语句发送通知不能包在事务里否则如果WebSocket推送的IO等待时间过长会拉长锁表时间。第二个教训是Excel导入导出别图省事用EasyExcel。基层系统最原始的录入方式是Excel批量导入很多考勤数据也是月底从Excel里导出来的。我当时评估过直接用Apache POI写但发现处理日期格式、空行、合并单元格这些细节太耗时间最后用了EasyExcel一个注解就能搞定字段映射。如果你也要做这层强烈建议直接用成熟的第三方库别自己封装POI那是典型的重复造轮子。第三个教训是定时任务和排班生成的耦合问题。系统里有个 Scheduled 每月1号凌晨自动生成下月排班表的需求。但实际运行发现如果1号是节假日或系统维护窗口定时任务被跳过等管理员上班时才发现下月排班还没生成。这里我的改进是定时任务只管生成对状态存到排班生成记录表里管理员页面上加了一个“立即生成”按钮一旦发现这月排班缺失手动触发即可。自动化兜底这件事是所有内部系统的隐形成熟度指标。7.3 非功能性问题的低调处理还有两个也许不那么常见但确实遇到的坑值得说。第一个是MySQL执行批量插入时因为数据量大而占满内存。排班引擎一次要生成一个月的排班记录20人 × 30天 × 每天1-2个班次差不多1000多条数据。如果逐条insert太慢且容易卡死连接如果一次性批量拼成一条巨长SQL又可能超出MySQL的max_allowed_packet限制。我的解决方式是MyBatis-Plus的saveBatch底层会按千条分批执行实测性能很好且不会超限。第二个是凌晨定时任务和日常请求高峰期的时间错峰。如果系统在8点上班前要出班次结果排班生成任务跑在凌晨3点这本来没问题。但考勤统计的定时任务如果也安排在同一时间凌晨的时候会有一波数据库并发高峰可能影响班里同事通宵值守时的打卡记录。对于一些敏感数据的导出我还给导出任务加了下载队列再多的请求也不会把数据库连接池打满。8. 写在最后的实操心得项目开发到上线最大的体会其实不在技术本身。基层调度系统的核心难点往往在于它要改变一群人多年来形成的习惯。所以我建议看到这里的朋友做这类系统的时候一定不要把排班规则定得太死板给调度员预留足够的手工调优空间给员工预留申请、申诉的通道系统才能真正被用起来。第二个建议是先把Excel表格搞清楚再动数据库设计。基层现在业务的真实流转都体现在那一张张排班表里你花两天时间仔细看一遍他们手填的Excel比你凭空设计十张表更靠谱。第三个建议是关于替补人员的建模。基层调度很常见的问题是一个人请假需要找人顶上。如果你只设计排班表和任务表没有单独维护一份“可替补人员池”那当调度员点开某个空岗时系统就给不出“谁能代班”的推荐列表。做这类系统时一定要把人员能力标签和可用状态加上它会成为整个系统最实用的功能之一。最后说一个小技巧排班日历的前端展示不要用普通table实现那个在移动端体验很差。建议直接用CSS Grid写成真正的日历网格天然支持响应式而且格子间的拖拽交互处理起来也方便得多。投用之后基层班组大多是用手机浏览排班结果的这个体验升级值得做。