news 2026/9/17 10:13:12

SpringBoot+Vue考勤管理系统从0到1开发实战

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
SpringBoot+Vue考勤管理系统从0到1开发实战

去年接了一个公司内部的考勤管理系统项目,技术栈定的是SpringBoot+Vue。需求方一开始说得轻巧——“就是上下班打个卡,统计一下迟到早退”——等真正调研完各部门的意见,才发现考勤系统属于典型的“看起来简单、做起来琐碎”的类型:排班规则、请假审批、加班调休、节假日判断、月度统计、导出报表,每一块都能延伸出一堆细节。

这篇文章我打算把整套系统从0到1的实现思路完整梳理一遍。重点不是贴大段大段的完整代码,而是讲清楚“为什么这么设计”和“哪些地方必须提前想明白”。如果你正准备用SpringBoot+Vue写公司考勤管理系统,不管是给企业内部用还是作为毕业设计项目,这篇文章里拆解的这些问题你都绕不开。我会从需求边界、数据库设计、后端核心逻辑、前端页面组织、权限控制一直讲到部署上线,最后把我实际踩过的坑和排查过程完整贴出来。

1. 考勤系统的真实场景:先搞清楚“管什么”再动手写代码

1.1 手工考勤的痛点,远远不止“统计麻烦”这么简单

很多团队一开始用Excel管考勤,几十个人的时候还好,超过一百人就开始失控了。我在项目调研阶段看到的典型现象是:HR每个月要花两三天收集各部门的Excel,还得手工核对请假单、加班申请、调休记录,经常对不上账。员工那边也不舒服,忘打卡了要找主管签字补卡,补卡流程走一圈下来比加班还累。

更麻烦的是排班。有的部门是固定早九晚六,有的部门是早晚班轮换,还有销售团队根本不坐班但要打卡外勤。如果用一张excel存所有规则,公式写到后面根本没人敢动。所以考勤系统在企业管理类项目里,真正的核心价值不是“替代手工记录”,而是“把规则统一收口”。系统上线以后,HR不再需要理解每个人的出勤状态是怎么算出来的,所有判定交给代码,她要做的只是处理异常和审批。

1.2 功能边界画清楚:MVP阶段先做这四件事

和很多项目一样,考勤系统最怕一开始就贪大。我建议按照这四块来做第一版,基本能覆盖绝大多数中小公司的需求:

  • 打卡管理:支持上下班打卡、外勤打卡,记录打卡时间和位置信息,补卡申请走审批流程。
  • 排班管理:管理员维护班次(早晚班、正常班、弹性班),给不同部门或员工分配排班规则。
  • 请假与加班:请假(事假、病假、年假、调休)、加班申请、审批流流转,审批通过后自动影响考勤统计。
  • 统计与导出:按月度生成考勤汇总表,包含出勤天数、迟到次数、早退次数、缺卡、请假时长、加班时长,导出Excel给HR。

先别急着做自动扣薪、人脸识别打卡、钉钉/企业微信集成这些高级功能。第一版上线跑顺了,第二版再加都来得及。我见过太多项目死在第一步——功能清单列了二十几项,最后连打卡都没做完。

1.3 角色与权限模型:三种角色起步就够用

权限设计上第一版建议做三种角色。

  • 员工:自己打卡、查看个人考勤记录、提交请假/加班/补卡申请。
  • 部门主管:审批本部门员工的申请,查看本部门考勤统计。
  • 系统管理员(HR):维护员工信息、排班规则、班次,处理所有审批,查看全员统计并导出报表。

不要一开始就做多级审批链,比如“主管→经理→HR”三层,第一版全部做成一级审批就好。等系统稳定了,再在流程表里加一个审批层级字段,就不会动到核心逻辑。

2. 技术选型:为什么2025年我仍然选SpringBoot+Vue这套组合

2.1 后端选SpringBoot,核心是生态成熟和团队招聘容易

现在写Java后端,SpringBoot基本是默认选项。它最大的优势不是性能,而是“约定大于配置”带来的开发效率和庞大到几乎什么问题都能搜到答案的社区生态。公司内部的考勤系统,需求变动频繁,今天要加一个字段,明天要调整一个计算规则,SpringBoot项目改起来非常快。MyBatis-Plus做CRUD、Spring Security做认证授权、EasyExcel做导出,每个环节都有现成方案。

性能上完全不用焦虑。考勤系统不是高并发场景,一个五百人的公司,每天早上8:30到9:00是打卡高峰,QPS也就几十。单机部署、MySQL数据库,绰绰有余。

2.2 前端选Vue,对后端开发者最友好,没有之一

前端框架里Vue和React我都用过,如果团队里后端工程师需要兼顾前端页面,Vue的上手成本明显低一截。它的模板语法接近原生HTML,响应式数据绑定理解起来直觉化,中文文档和社区也很完善。配合Element UI或者Element Plus,后台管理类的页面基本是“拼积木”——表格、表单、弹窗、日期选择器都是现成组件,改改字段就能用。

考勤管理系统的界面以数据表格、表单、日历视图为主,几乎没有复杂的前端交互,Vue这套技术栈属于“杀鸡用牛刀,但牛刀好上手”。

2.3 版本搭配清单:这不是迷信,是真的会踩坑

版本选择上我强烈建议稳妥优先。我的推荐组合是:

组件版本说明
JDK1.8企业级项目最稳,几乎不会出兼容性问题
SpringBoot2.7.x不要轻易上3.x,除非你有精力处理javax到jakarta的迁移
MyBatis-Plus3.5.x单表CRUD效率极高
MySQL5.7 / 8.08.0默认utf8mb4,推荐
Vue2.7 / 3.x新项目用3.x + Element Plus,老项目2.x + Element UI
Node16+Vue3配合Vite需要16以上

特别提醒:SpringBoot 3.x要求JDK17,并且把javax.servlet包迁移到了jakarta.servlet,很多老教程里的代码直接搬过来会报“包不存在”。如果不想踩这个坑,就老老实实SpringBoot 2.7.x + JDK8。我后面专门有一个章节讲版本踩坑,这里先不展开。

3. 数据库设计:考勤的核心不是“打卡记录表”,而是“排班表”

3.1 先忘掉打卡记录,想清楚“应该上什么班”才能判定“是否正常”

我见过不少考勤系统的数据库设计,第一张表就建“打卡记录表”,最后做到统计环节才发现一个致命问题:没法判断某个人某天算不算迟到。因为你不知道他当天应该几点上班。没有排班数据,打卡时间就是一堆无意义的数字。

所以数据库设计的第一步,是先设计班次表和排班表。班次表定义一天内有几个上班时段,比如正常班是09:00-18:00,早班是08:00-17:00,晚班是14:00-22:00。排班表则记录“某员工在某天执行哪个班次”。有了这两张表,后面的所有判断逻辑才有依据。

3.2 六张核心表的结构与关系

我第一版用的表结构,全库一共十几张表,核心六张如下:

  • sys_user:用户表,字段包括id、username、password(BCrypt加密)、real_name、department_id、role、status。
  • attendance_shift:班次表,字段包括id、shift_name、work_start_time、work_end_time、allow_late_minutes(宽限分钟)。
  • attendance_schedule:排班表,字段包括id、user_id、shift_id、work_date。
  • attendance_record:打卡记录表,字段包括id、user_id、work_date、check_in_time、check_out_time、status、source_type(正常打卡/补卡/外勤)。
  • leave_request:请假/加班申请单,字段包括id、user_id、type(leave/overtime/retroactive)、start_time、end_time、reason、status(pending/approved/rejected)、approver_id、approve_time。
  • attendance_summary:月度汇总表,字段包括id、user_id、month、total_days、late_count、early_count、absent_count、leave_hours、overtime_hours。

核心关系一句话就说得清:排班表告诉你“该上什么班”,打卡记录表告诉你“实际上打了什么卡”,两张表一比对,状态就出来了。汇总表是结果表,每月月底定时任务跑批生成,避免统计时实时计算拖慢查询。

3.3 打卡记录到底怎么存:一行一天,还是每次打卡一行

这里有个设计分歧。每次打卡存一行,结构上更“标准”,但要判断一个人某天是否迟到,需要先查他当天所有打卡记录,再取第一条和最后一条,逻辑上绕一步。而且如果某天打了四次卡(中午外出吃饭回来又打一次),判断逻辑会更乱。

我最终选择了“一天一行”的冗余设计:attendance_record每员工每天最多一条记录,check_in_time存当天第一次打卡时间,check_out_time存当天最后一次打卡时间。打卡接口在写入时先查当天是否已有记录,有则更新check_in或check_out,没有则插入。这样查询和统计都非常简单直接,一条记录就是一个完整的工作日状态。

代价是失去了“一天多次出入”的明细,但对于考勤管理这个场景完全够用。如果你需要统计午休外出时长之类的数据,可以再单独建一张明细表,但第一版不必。

3.4 索引规划:这张表一个月几万行,索引设计要提前想

按五百人规模计算,一个月的打卡记录大约1.5万行,一年18万行。这个量级不大,但查询模式很固定,索引还是要建好。我实际使用的索引建议:

  • attendance_record:联合唯一索引(work_date, user_id),防止重复数据;单独索引(user_id)用于个人查询。
  • attendance_schedule:联合唯一索引(user_id, work_date),保证一人一天一条排班。
  • leave_request:索引(user_id)和个人待办查询;索引(status)用于管理员查看待审批列表。
  • attendance_summary:联合唯一索引(user_id, month)。

联合唯一索引最大的好处是在数据库层面兜住了并发重复插入的问题。比如用户在8:59:59同时用手机和电脑各打了一次卡,如果没有唯一索引,就可能产生两条记录,一天一行模型就废了。

4. 后端核心实现:打卡接口、状态判定、统计导出这四块是重头戏

4.1 打卡接口怎么设计才能尽量防止“代打卡”

打卡接口看起来就是插入一条记录,但有几个细节必须处理。

第一个是来源识别。我设计的打卡接口为POST /api/attendance/checkin,参数包含latitude、longitude、address。员工在公司范围外打卡时,前端可以弹出确认框让用户选择“外勤打卡”,后端会记录source_type=outside。当然,第一版不做GPS围栏强校验,因为考勤系统最忌讳的就是“技术限制太死导致员工打不了卡”。

第二个是重复打卡。前端按钮要防止连点,后端通过唯一索引和事务兜底。插入时使用INSERT ... ON DUPLICATE KEY UPDATE,如果当天已有记录,则根据当前时间更新check_in_time(取更早的)或check_out_time(取更晚的)。

第三个是时间窗口校验。正常上班打卡允许从班次开始前2小时到班次开始后allow_late_minutes+30分钟之间,超出这个窗口的记录标记为异常。比如正常班09:00上班,宽限5分钟,那么7:00到9:35之间打卡都算正常上班打卡,早于7:00的记为异常,晚于9:35的直接算缺卡。

核心代码如下,打卡记录的更新逻辑是关键:

@Transactional public AttendanceRecord checkIn(CheckInDTO dto) { String today = LocalDate.now().toString(); // 查询当天排班 AttendanceSchedule schedule = scheduleMapper.selectOne( new LambdaQueryWrapper<AttendanceSchedule>() .eq(AttendanceSchedule::getUserId, dto.getUserId()) .eq(AttendanceSchedule::getWorkDate, today) ); if (schedule == null) { throw new BizException("当天无排班,无需打卡"); } Shift shift = shiftMapper.selectById(schedule.getShiftId()); LocalTime now = LocalTime.now(); // 判断是上班打卡还是下班打卡 boolean isCheckIn = now.isBefore(shift.getWorkStartTime().plusMinutes(30)); AttendanceRecord record = recordMapper.selectOne( new LambdaQueryWrapper<AttendanceRecord>() .eq(AttendanceRecord::getUserId, dto.getUserId()) .eq(AttendanceRecord::getWorkDate, today) ); if (record == null) { record = new AttendanceRecord(); record.setUserId(dto.getUserId()); record.setWorkDate(today); if (isCheckIn) { record.setCheckInTime(now); } else { record.setCheckOutTime(now); } record.setSourceType(dto.getSourceType()); recordMapper.insert(record); } else { if (isCheckIn && (record.getCheckInTime() == null || now.isBefore(record.getCheckInTime()))) { record.setCheckInTime(now); } else if (!isCheckIn && (record.getCheckOutTime() == null || now.isAfter(record.getCheckOutTime()))) { record.setCheckOutTime(now); } recordMapper.updateById(record); } return record; }

这里的isCheckIn判断逻辑有一个隐含假设:如果员工中午12点打开APP,系统会认为当天没有上班打卡记录,把12点记成check_in_time。这样会不会有问题?会,但实际场景中很少有员工中午才来上班——如果真的来了,记成check_in反而是合理的。真正的极端情况是晚班员工下午上班时,isCheckIn判断要基于班次的work_start_time来算。

4.2 迟到、早退、缺卡的判定:状态计算要写成独立的Service

考勤状态计算是整个系统里最容易写乱的地方。我的建议是抽一个独立的AttendanceCalculator组件,输入是排班、打卡记录、请假单,输出是状态枚举。不要把这个逻辑散落在Controller或者Mapper里。

状态判定规则如下:

  • NORMAL:打卡时间在班次开始时间+宽限分钟内,且下班打卡时间不早于班次结束时间。
  • LATE:上班打卡时间晚于班次开始时间+宽限分钟,但未超过班次开始时间+60分钟。
  • EARLY:下班打卡时间早于班次结束时间,但晚于班次结束时间-60分钟。
  • ABSENT:全天无打卡,或只有一次打卡且时间异常。
  • HOLIDAY_LEAVE:当天有请假单且状态为approved。
  • REST:当天没有排班。

这里有一个容易忽视的点:如果员工早上迟到,晚上又早退,状态应该是什么?不能只记一个LATE或EARLY,要用一个字段存主要状态,再加一个字段存异常明细。我的做法是attendance_record表里加了一个status VARCHAR字段,存逗号分隔的标记,比如“LATE,EARLY”。统计的时候用FIND_IN_SET或者直接在Java里拆分,哪种都行,但一定要能表达组合状态。

4.3 月度统计:先聚合后补全,不要一条SQL想把什么都查出来

统计报表是考勤系统的门面,HR每个月看的就是这张表。刚开始我想用一个复杂的SQL把出勤天数、迟到次数、请假时长全查出来,结果SQL写了一百多行,查询性能还行,但可维护性极差——需求一变动,SQL就要重写。

后来换了个思路:先查明细,再在内存里聚合。逻辑分三步:

  1. 查出某员工某月所有attendance_record记录。
  2. 查出该员工该月所有已批准的leave_request记录。
  3. 遍历每一天,结合排班表判定当天状态,累加汇总。

五百个人一个月的明细也就一万多条,放内存里处理完全没有压力,而且逻辑清晰、改起来快。月底用定时任务把汇总结果写进attendance_summary表,HR查报表直接查表就行,不需要实时计算。

4.4 导出Excel:EasyExcel处理动态列头的方案

导出月度考勤表是HR最喜欢的功能。用EasyExcel,基础用法是定义实体类加注解,一行代码就能导出。但考勤报表有个麻烦点:列头可能是动态的。比如HR选择导出“2025年6月”,列头是一号到三十号,每个日期一列,员工每一行显示当天状态。这种动态列头用注解的方式就没法实现了。

我的做法是使用EasyExcel的动态头API,传入List<List >作为表头,不需要实体类:

public void exportMonthlyReport(String month, HttpServletResponse response) { List<List<String>> head = new ArrayList<>(); head.add(Arrays.asList("员工姓名", "员工姓名")); head.add(Arrays.asList("部门", "部门")); for (int day = 1; day <= daysInMonth; day++) { head.add(Arrays.asList(day + "日", day + "日")); } head.add(Arrays.asList("迟到次数", "迟到次数")); head.add(Arrays.asList("请假天数", "请假天数")); List<List<Object>> dataList = buildData(month); EasyExcel.write(response.getOutputStream()) .head(head) .sheet("考勤汇总") .doWrite(dataList); }

注意每个表头的List 里如果有多个元素,会生成多级表头。我不想生成多级,所以每列只放了一个元素。导出的文件名要注意处理中文,用URLEncoder编码,否则浏览器下载时会出现乱码文件名。

5. Vue前端:页面组织、日历视图、审批流的交互设计

5.1 Vue项目结构和axios封装:目录清爽比什么都重要

前端项目用Vue CLI或Vite创建都可以。我推荐按业务模块划分目录,不要全部塞在views下面当扁平的页面列表:

src/ api/ // 接口请求封装,按模块拆分 attendance.js leave.js user.js views/ dashboard/ // 个人考勤面板 admin/ // 管理员页面:排班、班次、人员管理 approval/ // 审批中心 report/ // 月度报表 components/ // 公共组件 router/ // 路由配置 store/ // Pinia或Vuex状态管理

axios封装要做的事情不复杂:统一baseURL、请求头带token、响应拦截器里统一处理401跳转登录页、错误信息ElMessage提示。最关键的一点是封装完后所有请求方法都返回Promise,页面里统一用async/await,别在组件里到处写.then地狱。

5.2 考勤日历视图:v-calendar组件改造成本项目需要的样式

考勤系统的前端页面里最有技术含量的就是日历视图。员工个人页面要把每个日期标记成绿色(正常)、红色(迟到/缺卡)、蓝色(请假)、灰色(休息)。实现方案有两种:

  • 用Element Plus的el-calendar,自定义date-cell插槽。
  • 用独立的v-calendar组件,功能更强但需要引入额外依赖。

我第一版用的是v-calendar,因为它支持月份切换动画、日期范围高亮、事件标记API很完善。给每个日期设置一个自定义属性,然后在cell插槽里根据属性渲染不同颜色的小圆点:

<v-calendar v-model="calendarDate" :attributes="calendarAttrs"> <template v-slot:day-content="{ day, attributes }"> <div class="day-cell"> <span>{{ day.day }}</span> <div class="status-dots"> <span v-for="attr in attributes" :key="attr.key" class="dot" :style="{ background: attr.customData.color }" /> </div> </div> </template> </v-calendar>

日历数据来源是后端接口返回的当月状态JSON,前端按日期映射成attributes数组。这个方案实测下来页面很流畅,五百人规模下没有卡顿。

5.3 审批流的前端状态机:按钮状态跟着流程走

请假/加班/补卡申请的前端页面,核心是“不同角色看到不同操作”和“流程状态不同按钮状态不同”。我总结了一个简单的状态机思路:

  • 申请单状态存后端数据库,前端拉到一个字段叫approvalStatus,取值是PENDING/APPROVED/REJECTED。
  • 员工端列表:PENDING的显示“催审批”按钮,APPROVED的什么都不显示,REJECTED的显示“重新申请”。
  • 主管端待办列表:只显示PENDING的单子,操作按钮是“通过”和“驳回”,驳回时必填理由。
  • 管理员端:可以看到全部单子,包括已审批的历史记录,便于审计。

这个状态机不要写在每个页面里,抽成一个utils函数,输入是当前用户角色和申请单状态,输出是可见的按钮列表。这样改需求时只改一个函数,不会出现“列表页改了审批页忘了改”的问题。

5.4 动态路由与按钮权限:Vue Router判断用户角色

前端权限控制分两层。第一层是路由守卫,每个人登录后只能跳转到自己角色允许访问的页面。第二层是页面内的按钮权限,比如员工看不到“导出报表”按钮,主管看不到“排班管理”入口。

我用的方案是:路由meta里配置roles数组,router.beforeEach里判断当前用户角色是否在允许列表内。按钮权限则用自定义指令v-permission,指令内部比较用户权限码和后端返回的权限列表,没有权限就移除DOM元素:

// 自定义指令:v-permission="'attendance:export'" app.directive('permission', { mounted(el, binding) { const required = binding.value; const userPerms = useUserStore().permissions; if (!userPerms.includes(required)) { el.parentNode && el.parentNode.removeChild(el); } } });

这里的permissions列表是登录成功后后端返回的权限码数组。虽然第一版只做了三种角色,权限码已经按按钮粒度分配了,后面扩展新角色会省很多事。

6. 前后端联调与权限控制:JWT只是第一步,会话管理才是关键

6.1 JWT认证流程:生成、签发、拦截三步走

前后端分离项目的认证方案,JWT是主流。流程不复杂:用户输入用户名密码,后端校验通过后生成一个token返回,前端存在localStorage里,后续每次请求在Authorization头里带上,后端拦截器解析token并设置当前用户上下文。

生成token时建议把用户ID、用户名、角色放进去,但不要放敏感信息。过期时间设置成8小时,前端在axios响应拦截器里捕获401,跳转登录页并清除本地token。

有一个细节要注意:JWT是无状态的,服务端无法主动让某个token失效。如果员工离职了,管理员需要能强制该账号下线。解决方案有两种:一是维护一个token黑名单表,二是缩短token有效期并用refresh_token刷新。第一版考勤系统用黑名单表更简单——在sys_user表加一个token_version字段,每次登录生成新token时带上tokenVersion,拦截器里比较当前用户tokenVersion和token里的是否一致,不一致就拒绝。

6.2 后端拦截器和前端路由守卫的双保险

权限控制不能只靠前端隐藏按钮,真正的安全防线在后端。每个接口的Controller方法上都要加权限注解,比如@PreAuthorize("hasAuthority('attendance:export')")。前端隐藏按钮是为了用户体验,后端拦截才是安全兜底。

我用Spring Security + Sa-Token的组合,比纯Spring Security要轻量不少,学习成本低。Sa-Token的StpUtil.getLoginId()就能拿到当前登录用户ID,比从token里手动解析要省事得多。

6.3 跨域问题:为什么前端请求老是被浏览器拦截

开发环境前后端分离跑在两个端口(如前端8080,后端8081),必然遇到跨域。这个问题我在项目里处理了两遍,第一次是开发环境的代理,第二次是测试环境的Nginx反向代理。

开发环境最简单的方案是配置Vite或Vue CLI的proxy代理,前端请求统一发到前端端口,由开发服务器转发到后端:

// vite.config.js server: { proxy: { '/api': { target: 'http://localhost:8081', changeOrigin: true } } }

这种方式浏览器看到的所有请求都指向同一个源,不存在跨域问题。生产环境用Nginx把/api路径反向代理到后端服务。绝对不要在SpringBoot里配置全局CORS允许所有来源,那等于把API裸奔在公网上。

7. 踩坑实录:这几个问题让我加了好几天班

7.1 SpringBoot版本太高引发的依赖冲突

项目一开始我图新鲜用了SpringBoot 3.0.2 + JDK17,结果刚起步就踩坑。MyBatis-Plus当时的最新版勉强支持,但一些老牌Excel导出库、代码生成器全部不兼容javax,报ClassNotFoundException。而且Spring Security 6的配置方式和5完全不一样,网上大部分教程都是基于5.x的,照抄根本跑不起来。

排查链路是这样的:先看到启动报错NoClassDefFoundError,定位到是security包里的javax.servlet.Filter找不到。用mvn dependency:tree查依赖,发现SpringBoot 3.0.2内部引用的jakarta.servlet-api,而MyBatis-Plus的某个插件还依赖javax.servlet-api,两者互相冲突。最后决定不折腾了,降级到SpringBoot 2.7.9 + JDK8,所有问题消失,连配置都不用改。

给新手的建议:做企业管理系统,SpringBoot老老实实用2.7.x,别追新。

7.2 Vue打包后布局异常:路由mode和静态资源路径的坑

本项目第一个上线版本打包部署后,页面白屏,控制台报一堆资源404。排查发现是publicPath的问题。Vue CLI默认publicPath是绝对路径/,部署到服务器二级目录后就找不到了。把它改成相对路径./后,资源加载正常。

但接着又出现第二个问题:路由刷新时404。因为用了Vue Router的history模式,刷新页面时Nginx找不到对应的后端路由,返回404。排查后确认需要配置Nginx的try_files指令,让所有前端路由都回退到index.html:

location / { root /usr/share/nginx/html; index index.html; try_files $uri $uri/ /index.html; }

之后刷新就正常了。这两个问题叠加在一起,排查花了大半天,但原因都很简单,一个是路径配置,一个是服务器回退配置。

7.3 考勤系统最隐蔽的坑:时区问题

考勤系统对时间极其敏感,时区不对全盘皆输。我测试时发现了一个诡异现象:员工明明17:00下班打卡,数据库里存的却是09:00。排查过程先查前端,发现前端调试时显示的LocalTime是正常的;再查后端接收参数,发现传给后端的字符串也是17:00;最后查数据库连接串,发现JDBC URL里少了一个关键参数。

MySQL驱动连接串必须显式指定serverTimezone,否则会使用JVM默认时区(我服务器上配置的是UTC),插入时相差8小时:

jdbc:mysql://localhost:3306/attendance?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai

Java后端涉及时间的一律用LocalDateTime和LocalTime,不要用Date和Timestamp。Java 8新的时间API不包含时区信息,配合MySQL DATETIME类型,不会出现Date类那种时区转换的坑。前端用dayjs格式化时间字符串传给后端,后端用LocalTime.parse接收,两端统一用"HH:mm:ss"格式,问题基本就避免了。

7.4 EasyExcel动态列导出的两个坑

动态列头导出时遇到两个问题。第一个是每个单元格的样式错乱——动态列的表头里如果合并了单元格,必须保证表头每行的长度一致。我在表头里混用了单列和多列,导出后Excel提示文件损坏。解决办法是让所有列的表头List长度一致,都只放一个元素。

第二个是导出大数据量时内存溢出。五百人一个月的明细不算多,但如果你把表头和数据一次性全加载进内存,再一次性写入,内存占用还是很可观的。EasyExcel提供了分页查询配合分批写入的能力,每查1000条就write一次,最后调用finish方法。我实际测试下来,内存占用从峰值300MB降到了80MB以内,效果非常明显。

8. 部署上线与运维细节:能用Docker就用Docker,备份别偷懒

8.1 前后端分离部署:Nginx + JAR包 + MySQL的经典组合

生产环境我用的是一台2核4G的云服务器,部署结构如下:

  • 前端:npm run build生成dist目录,用Nginx托管静态文件。
  • 后端:SpringBoot打成JAR包,用systemd配置开机自启。
  • 数据库:MySQL 8.0,独立部署,每天凌晨自动备份。
  • Redis:第一版没用到,第二版考虑做缓存和token黑名单时会加。

后端启动命令建议加上JVM参数调优:

nohup java -Xms256m -Xmx512m -jar attendance-system.jar --spring.profiles.active=prod > app.log 2>&1 &

512MB堆内存对这个应用完全够用。如果服务器内存吃紧,-Xms和-Xmx可以都配成200MB,跑起来也没问题。

8.2 数据库查询优化:除了索引,还要关注分页深翻页

系统上线一个月后,管理员反馈“考勤记录查询页面变慢了”。排查发现是管理员习惯用默认排序翻到最后一页,MySQL深翻页offset到几十万行时性能急剧下降。

优化方案是改成“查询条件带时间范围的游标分页”,或者用延迟关联,子查询先查出主键ID再回表:

SELECT * FROM attendance_record WHERE id IN ( SELECT id FROM attendance_record WHERE user_id = ? AND work_date BETWEEN ? AND ? ORDER BY work_date DESC LIMIT 500, 100 );

由于考勤查询几乎都会带员工ID和时间范围这两个条件,联合索引(user_id, work_date)命中后,深翻页的性能问题基本就没有了。另外建议管理员查询时默认限制最大查询范围,比如一次最多查三个月,减少一次捞全年的情况。

8.3 月底自动跑批:定时任务生成汇总报表

月度汇总用SpringBoot自带的@Scheduled注解就能实现。配置一个cron表达式,每月1号凌晨1点执行生成上个月的汇总:

@Scheduled(cron = "0 0 1 1 * ?") // 每月1号凌晨1点 public void generateMonthlySummary() { String lastMonth = LocalDate.now().minusMonths(1).toString().substring(0, 7); // 遍历所有员工,计算考勤数据,写入attendance_summary }

跑批逻辑要考虑幂等性:如果因为服务器停机导致跑批失败,下个月1号补跑时要先删除上个月的汇总记录再重新生成,否则会出现重复数据。简单做法是在insert之前先执行delete where month = 目标月份。

备份方面,每天凌晨用mysqldump备份全库,保留最近7天的备份文件,加上一个异地备份(用另一台服务器的定时任务从那边拉取)。考勤数据涉及员工工资核算,丢数据的后果很严重,备份不能省。

写在最后的个人经验

这套系统从需求调研到上线用了大约八周,其中一半时间花在需求确认和数据库设计上,真正写代码的时间并不多。回头看,最值得的投资是花了整整两天把排班模型和状态判定规则定义清楚,后面所有模块的开发都是在这个基础上顺水推舟。

如果再让我重做一次,我会提前把“消息通知”机制纳入第一版设计。考勤系统天然有通知需求——审批通过要通知员工,员工提交申请要通知主管,忘打卡了系统应该提醒。第一版我全部用站内信实现,员工根本不看,后来加了邮件通知效果才好一些。如果你们的公司用企业微信或钉钉,第二版优先接入它们的消息推送,员工的感知度会高很多。

另一个经验是:考勤系统最容易翻车的不是技术,而是考勤规则。建议上线前让HR部门把所有规则写成文档,你对照文档实现,再让HR参与测试验收。一个“迟到宽限5分钟”的规则看起来简单,但它和“9:05打卡算不算迟到”“9:35打卡算不算缺卡”是关联的,规则之间一定要做全组合测试。

最后分享一个小技巧:开发阶段把打卡接口的时间参数做成可配置的mock模式,前端可以传入指定时间,这样测试迟到、早退、缺卡这些场景时不用真的等到对应时间点去点按钮。这个功能在演示给领导看的时候特别好用。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/17 10:43:33

JavaScript中null与undefined的区别与最佳实践

1. 为什么我们需要区分null与undefined&#xff1f;在JavaScript开发中&#xff0c;null和undefined这两个特殊值经常让开发者感到困惑。上周我在代码审查时发现&#xff0c;团队里有位三年经验的工程师还在用比较它们&#xff0c;这直接导致了线上环境的一个边界条件bug。今天…

作者头像 李华
网站建设 2026/9/17 10:31:03

Windows拖放.sh脚本到WSL自动运行的桥接方案

Windows 桌面收到一个 .sh 脚本&#xff0c;很多人第一反应是开终端、敲cd /mnt/c/...、再bash xxx.sh。脚本少还好&#xff0c;一天要来回跑十几个临时脚本时&#xff0c;光是路径转换就够烦。我之前也试过给 .sh 配置文件关联&#xff0c;双击交给 WSL 执行&#xff0c;但 Wi…

作者头像 李华
网站建设 2026/9/17 10:41:55

Sitemap生成与提交前7项硬性检查实战指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/17 10:44:20

基于OpenCV与Django的答题卡识别判分系统开发实战

简介&#xff1a;这套基于Python与Django的计算机视觉答题卡识别及判分系统&#xff0c;是一份适合毕业设计、课程设计及Web开发学习者参考的完整工程。项目整合了图像预处理、特征提取、文字识别与自动评分流程&#xff0c;并配有前端交互、后端逻辑及MySQL数据库&#xff0c;…

作者头像 李华
网站建设 2026/9/17 10:40:01

CentOS 7安装Git全指南:yum与源码编译详解

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/17 10:24:26

架构测试的双视图:静态依赖检查+动态运行观测,缺一不可

架构测试这件事&#xff0c;圈子里有个很普遍的误区&#xff1a;不少人以为把分层依赖检查挂到 CI 上&#xff0c;静态扫描全绿&#xff0c;架构就算测过了。可真实情况往往是——静态视图一切正常&#xff0c;线上却因为一次反射调用绕过分层、或者某个服务超时被重试放大&…

作者头像 李华