☰
SpringBoot+Vue+MyBatis+MySQL大学生考勤系统全栈开发实战
2026/10/5 7:27:51 网站建设 项目流程

做大学生考勤系统,很多人第一反应是“不就是一个打卡加统计嘛”,但真正落地的时候,你会发现“打卡”两个字背后藏着定位、时间窗口、请假联动、防作弊、定时补缺、多角色权限一整套逻辑。这个项目用SpringBoot+Vue+MyBatis+MySQL四件套实现,算是国内高校里最典型、也最值得吃透的一套技术组合。无论你是拿它当毕业设计、实训项目,还是想把这套全栈流程跑通后写进简历,这篇东西都值得认真看完。我会把数据库设计、后端接口、前端交互、部署避坑整个链路都拆开讲,把我踩过的坑和改过的版直接给你。

1. 项目概述与核心需求解析

1.1 大学生考勤系统到底要解决什么问题

高校考勤和公司考勤有本质区别。公司考勤的最终目的是算工资,迟到扣多少钱、旷工扣多少天,逻辑非常线性。学校考勤不一样,它的产出是“平时成绩”和“预警记录”:迟到多少次扣多少平时分、缺勤累计几次触发辅导员约谈、请假是否影响评优。这意味着系统不能只记录“几点到”,还要维护一套可配置的规则。

跟真实的课堂场景对比一下就知道痛点在哪。

传统点名方式,老师拿纸质名单一喊,五六十人的大课喊完至少五分钟,一个学期下来Excel统计能让人崩溃。更麻烦的是代签问题,一个宿舍四个人,后面的室友能帮前面全答到。到期末算平时分的时候,老师要翻每节课的记录,人工折算比例,出错率极高。

所以这个系统要解决的核心问题是四个:快速签到、防代签、自动统计、请假联动。快速签到对应移动端页面,学生扫码或定位就能完成;防代签对应定位范围和IP/MAC指纹校验;自动统计对应后端定时任务和动态SQL聚合;请假联动对应审批通过后自动把时间段内课程置为“请假”状态。四件事做扎实了,系统才能真的用起来,而不是一个花架子CRUD。

1.2 技术选型:为什么是SpringBoot+Vue+MyBatis+MySQL

这套组合在2025年依然是国内Java全栈的“标准答案”之一。SpringBoot负责后端快速搭建,内嵌Tomcat、自动配置、起步依赖这些特性让项目能在一个小时内跑起来;Vue负责前端组件化开发,学生端、教师端、管理员端拆成独立视图,路由守卫做权限拦截;MyBatis负责数据库访问,相比JPA那种全自动ORM,MyBatis的SQL完全由自己掌控,尤其适合考勤统计里那种“多条件动态拼接”的场景;MySQL负责数据存储,免费、稳定、生态齐全。

选这套而不是别的,背后有三个现实考量。

第一是学习成本可控。SpringBoot的自动配置屏蔽了大量Spring底层细节,Vue有成熟的中文文档和Element UI/Element Plus组件库,MyBatis上手简单,这三个技术点都是面试高频,学会了之后复用性很强。第二是团队协作清晰。前后端分离之后,前端同学和后端同学可以并行开发,接口文档定好就能推进。第三是部署成本极低。后端打包成Jar,前端构建成静态资源,一台2核4G的云服务器就能跑得很稳。

这里要提醒一句:SpringBoot版本不是越高越好。如果你手头的JDK还是8,Spring Boot 3.x根本跑不起来,老老实实用2.7.x系列。很多实训项目环境老旧,为了“最新”两个字把时间浪费在版本兼容上,得不偿失。Vue也是同理,Vue3+Element Plus是主流,但如果你的团队只熟Vue2,用Vue2+Element UI同样能完成项目,关键是业务逻辑要正确。

2. 数据库设计与核心业务模型

2.1 用户与角色模型:一张基础表还是三张表

大学生考勤系统里有三类用户:学生、教师、管理员。学生需要学号、班级、入校年份;教师需要工号、职称、所属学院;管理员只需要登录账号。很多新手会建三张彼此独立的设计表,然后用一张“用户角色关联表”去映射,结论是做成了RBAC权限模型。

我的建议是:别把简单问题复杂化。

对于这种单体中小型项目,一张sys_user基础表存账号、密码、角色,再配一张student_profile和一张teacher_profile做扩展,性价比最高。sys_user表里只放用户ID、用户名、密码、角色、状态、创建时间。学生信息放到扩展表,通过user_id关联。这样既避免了JOIN三张以上的大麻烦,又保留了扩展空间,以后要加辅导员角色,照葫芦画瓢加一张profile就行。

密码存储务必使用BCrypt加密,不要用MD5。MD5撞库太容易了,Spring Security或者SpringBoot自带的spring-security-crypto包里就有BCryptPasswordEncoder,一行代码搞定加密和校验。如果项目里用的是shiro或者其他鉴权框架,同样优先选择BCrypt。

至于“一个学生能不能兼任教师助教”这种边界情况,别纠结,系统设计阶段直接定死:一个账号一个角色。助教需要管理权限,让老师给单独账号,不要做动态多角色,否则Vue前端的路由守卫和后端的权限拦截都要跟着复杂一倍。

2.2 考勤记录与请假流程的数据模型

考勤记录表是整个系统的核心,字段设计直接决定后续统计是否痛苦。我的核心表示意如下:

  • attendance_record
    • id(主键)
    • student_id(学生ID)
    • schedule_instance_id(排课实例ID,而不是存一个笼统的course_id)
    • check_in_time(签到时间)
    • check_out_time(签退时间,按需使用)
    • status(NORMAL、LATE、EARLY、ABSENT、LEAVE)
    • source_type(1-定位签到 2-扫码签到 3-教师代签 4-管理员补录)
    • latitude / longitude(签到时的定位坐标,用于防作弊溯源)
    • remark(备注)

status字段是整个业务状态机的核心。NORMAL表示正常出勤,LATE表示迟到,EARLY表示早退,ABSENT表示缺勤,LEAVE表示请假被批准。这里有一个关键决策:请假记录不要直接改考勤记录,而是在统计时用JOIN判断。如果你在status里存了LEAVE,那么请假被驳回之后必须把LEAVE改回原状态,操作链路长且容易出错。更稳的做法是:考勤记录只存实际签到情况,统计出勤率时,如果某学生没有考勤记录但有通过状态的请假单,则该时段计为“请假不算缺勤”。

请假流程单独一张leave_request表,字段包括学生ID、开始时间、结束时间、请假类型(事假/病假)、请假理由、附件路径、审批教师ID、状态(PENDING/APPROVED/REJECTED)、审批备注、创建时间。这张表的核心作用是和排课实例做时间重叠判断。

2.3 排课实例:避免统计混乱的关键设计

这是整个数据库设计里最容易被忽视、但最影响后期开发效率的一张表。很多考勤系统会直接设计“课程表”,字段是课程ID、上课时间、上课星期、上课节次。然后考勤记录直接挂课程ID,统计“高等数学第3周出勤率”的时候,就得把“课程ID+周次+星期+节次”四个条件拼SQL,写起来又臭又长。

我推荐的方案是增加schedule_instance表,也就是“排课实例表”。一门课程“高等数学”,在整学期里被拆成多个实例,每个实例代表“第X周星期X第X节在X教室上课”。这个实例有自己的主键ID,考勤记录直接关联这个实例ID。这样一来,某一节课的应到人数、实到人数、缺勤名单都是天然的分组维度。

CREATE TABLE schedule_instance ( id BIGINT PRIMARY KEY AUTO_INCREMENT, course_id BIGINT NOT NULL, teacher_id BIGINT NOT NULL, week_number INT NOT NULL COMMENT '第几周', day_of_week INT NOT NULL COMMENT '星期几,1-7', start_time TIME NOT NULL, end_time TIME NOT NULL, classroom VARCHAR(100), status TINYINT DEFAULT 1 );

配合这个表,课后统计就变得非常优雅:按schedule_instance_id分组查考勤记录,一眼看出这节课谁没来。定时任务生成缺勤记录时,也是遍历当天的schedule_instance,而不是遍历课程表。这个设计初期会多花半小时建表,后期帮你省下大把改SQL的时间。

3. 后端实现:SpringBoot+MyBatis实战

3.1 项目骨架与MyBatis基本配置

后端工程我习惯用Maven构建,清清爽爽分包。controller层只做参数接收和结果返回,service层写业务逻辑,mapper层只负责数据库操作,entity对应数据库表,dto用于前端交互。common包里放统一返回结果类Result、全局异常处理器、常量定义。config包放跨域配置、MyBatis配置、定时任务开关。

application.yml里MyBatis的关键配置有两行,一个是mapper-locations指定XML文件位置,一个是map-underscore-to-camel-case开启驼峰映射。

mybatis: mapper-locations: classpath:mapper/*.xml type-aliases-package: com.example.attendance.entity configuration: map-underscore-to-camel-case: true

这个驼峰映射必须开启。数据库字段create_time在Java实体里对应createTime,如果不开启映射,查询结果里createTime就是null,排查起来毫无头绪。很多新手卡在这一步卡半天,其实就少这一行配置。

实体类里的时间字段,建议直接用LocalDateTime,配合MyBatis的TypeHandler(比如MyBatis-Plus的LocalDateTimeTypeHandler,或者自己实现一个),不要用java.util.Date。Date在前后端JSON序列化和时区处理上太容易出问题了,LocalDateTime配合Jackson的配置反而干净。

3.2 动态SQL写出灵活的考勤统计

考勤统计的特点是查询条件不确定:教师端可能按课程筛选,也可能按日期范围筛选,还可能按班级筛选;管理员端可能按学院筛选,也可能按学生姓名模糊搜索。这些条件如果写死SQL,那就得写十几个接口,动态SQL就是干这个的。

MyBatis的if+where标签是最基础也是最核心的组合。比如统计某个时间段内各状态的数量,可以这样写:

<select id="selectAttendanceStat" resultType="map"> SELECT DATE_FORMAT(a.check_in_time, '%Y-%m-%d') AS day, COUNT(*) AS total_count, SUM(CASE WHEN a.status = 'LATE' THEN 1 ELSE 0 END) AS late_count, SUM(CASE WHEN a.status = 'ABSENT' THEN 1 ELSE 0 END) AS absent_count FROM attendance_record a <where> <if test="courseId != null"> AND a.course_id = #{courseId} </if> <if test="startDate != null and startDate != ''"> AND a.check_in_time &gt;= #{startDate} </if> <if test="endDate != null and endDate != ''"> AND a.check_in_time &lt;= #{endDate} </if> <if test="studentId != null"> AND a.student_id = #{studentId} </if> </where> GROUP BY day ORDER BY day DESC </select>

这里要注意几个细节。第一,XML里的小于号要用<转义,大于号用>,不然XML解析直接报错。第二,参数是String类型时,if判断要同时判null和isEmpty,不然前端传个空字符串也会拼进SQL。第三,GROUP BY之后ORDER BY用别名day是可以的,但WHERE里不能用别名。这些坑看起来小,实际调试的时候非常消耗时间。

3.3 签到接口:定位校验与时间窗口判定

签到接口是后端最核心的接口,逻辑分三步走。

第一步,前端通过浏览器的Geolocation API或者微信小程序的定位接口获取学生的经纬度,连同课程实例ID一起提交到后端。后端拿到经纬度后,和该课程实例绑定的教室坐标做距离计算。这里推荐Haversine公式,因为地球是球面,直接用平面距离在纬度跨度大的地区会失真严重。

private static final double EARTH_RADIUS = 6371000; private double distance(double lat1, double lng1, double lat2, double lng2) { double radLat1 = Math.toRadians(lat1); double radLat2 = Math.toRadians(lat2); double a = radLat1 - radLat2; double b = Math.toRadians(lng1) - Math.toRadians(lng2); double s = 2 * Math.asin(Math.sqrt( Math.pow(Math.sin(a / 2), 2) + Math.cos(radLat1) * Math.cos(radLat2) * Math.pow(Math.sin(b / 2), 2) )); return s * EARTH_RADIUS; }

实际使用中,教室半径建议设置为80到120米。设置太严,GPS在室内的误差会导致学生明明在教室却签不上;设置太松,隔壁教学楼都能代签。我实践下来的经验是100米比较合理,如果教学楼比较密集,可以再收紧到70米。

第二步,时间窗口判断。先根据schedule_instance查到这节课的start_time和end_time。如果签到时间在start_time之前15分钟内,允许签到,状态为NORMAL;如果签到时间在start_time之后但未超过迟到阈值(比如10分钟),状态为LATE;如果签到时间超过迟到阈值且仍在课程时间内,可以允许签入,但状态标记为LATE,绝不允许“下课了才录入一条踩点记录”。这里要特别注意:下课后才来补签到,应该直接拒绝并提示“本课程已结束”,而不是无限放行。

第三步,写考勤记录。插入时需要带上lat/lng和source_type,这样后续如果发生“人不在教室但系统显示签到”的纠纷,可以调出记录溯源。同时,要防重复签到。同一student_id+schedule_instance_id在数据库里要加唯一索引,插入时先查后插,捕获DuplicateKeyException时返回“你已完成签到”。

3.4 定时任务自动生成缺勤记录

手工补录缺勤效率太低,课程多的时候老师也不可能记得每个没来的学生。所以要在后端写一个定时任务,每天课程全部结束后自动扫描当天的考勤情况,生成缺勤记录。

SpringBoot里用@Scheduled注解就能实现。每45分钟扫描一次,比如当天21:30开始处理白天的考勤。

@Scheduled(cron = "0 30 21 * * ?") public void generateAbsentRecord() { List<ScheduleInstance> instances = scheduleInstanceMapper.findByDate(LocalDate.now()); for (ScheduleInstance instance : instances) { List<Long> absentStudentIds = studentMapper.findAbsentByInstance(instance.getId()); for (Long studentId : absentStudentIds) { // 先查请假表,看是否已有审批通过的请假记录覆盖这个时间段 int leaveCount = leaveRequestMapper.countApprovedOverlap( studentId, instance.getStartTime(), instance.getEndTime()); if (leaveCount > 0) { continue; } // 再查考勤记录,已经签到的学生跳过 int attendanceCount = attendanceRecordMapper.countByStudentAndInstance(studentId, instance.getId()); if (attendanceCount > 0) { continue; } AttendanceRecord absentRecord = new AttendanceRecord(); absentRecord.setStudentId(studentId); absentRecord.setScheduleInstanceId(instance.getId()); absentRecord.setStatus("ABSENT"); absentRecord.setSourceType(0); absentRecordMapper.insert(absentRecord); } } }

这里最关键的细节有三个。第一,必须先查请假表,再查考勤记录。因为请假被批准的学生,理论上不会主动去签到,如果先查考勤就会把请假学生误判为缺勤。第二,要加幂等控制,也就是先查再插,否则定时任务每次执行都会重复生成缺勤记录。第三,定时任务只处理“已经结束”的课程,不要处理正在进行中的课程,否则下半场才来上课的学生会被误判。

3.5 请假流程的状态流转与冲突检测

请假流程相对简单,状态从PENDING到APPROVED或REJECTED,前端提交请假单,后端校验时间合法性,教师端审核。但有一个非常容易出问题的点:时间冲突检测。

学生提交请假申请时,系统必须校验“这段时间内有没有已经审批通过的请假重叠”。如果不做这个检测,可能出现一个学生同一时间段提交了两个不同事由的请假单,后端的考勤判定逻辑会被搞乱。

检测逻辑实际上就是区间重叠判断,假设已有请假记录时间是start1到end1,新提交的请假时间是start2到end2,只要满足start1 < end2 且 end1 > start2,就算重叠。

SELECT COUNT(*) FROM leave_request WHERE student_id = #{studentId} AND status = 'APPROVED' AND start_time &lt; #{endTime} AND end_time &gt; #{startTime}

如果返回值大于0,直接提示“该时间段已有请假申请,请先撤销原申请”。这个检测在插入之前执行就已经够用了,不用加复杂的锁。

请假被批准之后,前端调课表接口时,对应时间段内显示的课程状态要变成“已请假”。这一步不需要写回考勤记录,而是在查询接口里JOIN请假表判断,前面已经说过这个原则。到期末统计时,请假时间段的考勤记录不参与缺勤次数计算,但也不会计入出勤天数,而是单独一个“请假”分类。

4. 前端实现:Vue与交互细节

4.1 前端工程结构与路由权限设计

前端工程我用Vite或者Vue CLI创建,结构大致是views、components、router、store、api这几个目录。views下面按角色拆分:student/pages里的打卡页、课表页、请假页;teacher/pages里的课程管理、考勤统计、请假审批;admin/pages里的用户管理、班级管理、课程管理。components目录放一些公共组件,比如通用的时间选择器、上传组件、状态标签。

路由权限是前后端分离项目里最值得注意的一环。Vue Router的beforeEach钩子里,先判断是否有token,没有就踢到登录页;有token再判断目标路由的meta.roles是否包含当前用户的角色,不包含就踢到403页面。

router.beforeEach((to, from, next) => { const token = localStorage.getItem('token'); if (to.path === '/login') { next(); return; } if (!token) { next('/login'); return; } const roles = store.state.user.roles; if (to.meta.roles && !to.meta.roles.some(r => roles.includes(r))) { next('/403'); return; } next(); });

这里要注意一个细节,动态路由和静态路由的区别。对于学生、教师、管理员权限差别很大的系统,可以写死静态路由但通过meta控制访问权限。如果角色再复杂一点,后端返回菜单列表,前端addRoute动态添加。我的建议是:考勤系统最多三个角色,静态路由+meta检测完全够用,别搞动态路由,维护成本反而更高。

axios封装里,请求拦截器自动带上token,响应拦截器统一处理错误码。比如401跳登录、500弹错误提示。这一层一定要做,不然每个页面都要写try catch去处理登录过期,代码会爆炸。

4.2 学生端打卡页面的定位实现

打卡页面是整个系统里用户体验要求最高的地方。场景是:上课铃响了,学生点开手机页面,要在几秒内完成签到。所以我做成了一个大的圆形按钮,“立即签到”四个字,背景色用醒目的绿色,点击之后立刻展示当前定位状态和距离。

定位用浏览器原生Geolocation:

navigator.geolocation.getCurrentPosition( (position) => { const lat = position.coords.latitude; const lng = position.coords.longitude; // 提交给后端校验 this.submitCheckIn(lat, lng); }, (error) => { this.$message.error('定位失败,请检查是否开启定位权限,或改用扫码签到'); }, { enableHighAccuracy: true, timeout: 10000, maximumAge: 30000 } );

这里会遇到两个现实问题。第一是校园室内GPS信号弱,很多教学楼因为建筑结构问题,手机在教室里无法获取精确定位。我的解决方案是:前端定位成功后显示距离,当距离在100米内时直接允许签到,后端接口同样做校验;如果前端定位失败,允许学生改用扫码签到,教师在课堂上展示一个动态二维码,学生扫码即可签到。第二是部分浏览器要求页面必须在HTTPS环境下才能调用Geolocation。本地开发用localhost没问题,一旦部署到服务器,必须用HTTPS域名,否则定位API被浏览器禁用。这一点很多人在部署后才遇到,提前配好证书能省很多事。

签到成功后,页面要立刻反馈:显示签到时间、签到状态(正常/迟到)、距离教室位置。这样学生知道自己签上了没,也避免反复点击重复提交。重复提交统一由后端唯一索引兜底,前端也要在收到成功响应后禁用按钮。

4.3 教师端统计报表与可视化

教师端是数据消费的核心场景。老师关心三件事:今天谁没来、这个月每个人缺勤多少次、这门课的出勤率趋势。所以统计页面至少要有三个可视化模块。

我用ECharts做图表,第一个是出勤率折线图,按周展示,横轴是第1周到第18周,纵轴是出勤率百分比。这个数据来自一个接口,后端返回课程所有排课实例的应到人数、实到人数,前端算好百分比后直接渲染。第二个是缺勤预警柱状图,展示本学期累计缺勤超过3次的学生名单,红色高亮,方便老师重点关注。第三个是表格明细,支持按日期、按状态筛选,后端用动态SQL支持多条件查询。

这里有一个非常实用的优化:下载Excel。老师期末要交平时成绩表,不能让他们对着网页一个个抄。后端可以集成EasyExcel或Apache POI,把考勤统计结果导出为xlsx文件。导出接口要设计成异步任务,因为一个班几十人、一学期十几周的数据量虽然不大,但如果并发导出可能会导致接口超时。简单的做法是同步导出,数据量大的时候再加异步和文件存储。

前端页面还有一个容易被忽略的细节:筛选条件的默认值。默认显示“本学期”“本课程”,不要默认“全部时间”,否则统计图表加载慢且没有重点。日期范围选择器限制最长跨度为一个学期,避免学生选了三年数据导致查询超时。

5. 联调、打包与部署实战

5.1 前后端联调的跨域问题

开发环境下,前端跑在Vite的默认端口5173,后端跑在8080,这就是典型的跨域场景。解决方式有两种。

一种是在后端配置CORS,写一个配置类实现WebMvcConfigurer,允许指定来源。另一种是在前端配置代理,Vite的vite.config.js里设置server.proxy,把/api开头的请求转发到后端地址。我个人强烈推荐第二种方式,因为开发环境和生产环境可以保持同一个接口路径,打包后不需要改前端代码。

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

代理的好处是,请求发到前端的5173端口,Vite服务器再把请求转发到8080,浏览器看到的请求是同源的,不存在跨域问题。生产环境如果用Nginx,同样把/api路径转发到后端服务即可,前后端联调路径完全一致。

5.2 Vue打包放进SpringBoot的两种方式

很多人的目标是部署成单机版本,不想搞前后端两个服务,那最常用的方案就是把前端打包产物塞到SpringBoot的静态资源目录里。

前端执行npm run build,生成dist目录,里面是index.html、static等文件。第一种方式是把dist目录下的所有文件复制到后端工程的src/main/resources/static目录下,然后重新打包SpringBoot的Jar包。第二种方式是在后端pom.xml里配置maven-resources-plugin,构建时自动把前端dist目录拷贝到target/classes/static下,实现前后端一体化构建。

这里有一个必须处理的坑:Vue路由使用history模式时,页面刷新会404。因为SpringBoot默认只处理静态资源映射,/course/list这样的路径在服务端并不存在。解决方案是写一个WebMvcConfigurer的视图控制器,把非/api的所有路径转发到index.html。

@Override public void addViewControllers(ViewControllerRegistry registry) { registry.addViewController("/{path:[^\\.]*}") .setViewName("forward:/index.html"); }

这段配置的本质是让所有没有点号后缀的路径都交给前端路由处理。注意这里的正则排除了带点号的请求,这样静态资源(.js、.css、.png)不会被拦截。实现这个配置后,前端路由的刷新问题才算是彻底解决。

5.3 三种部署形态对比

部署形态主要有三种,对应不同使用场景。

第一种是单体Jar包部署,前端静态资源打进SpringBoot后,执行java -jar,适合实训演示和个人项目,推荐用这个。第二种是前后端分离部署,前端放到Nginx,后端打Jar包单独运行,中间通过/api路径转发,适合正式上线。第三种是Docker容器化部署,一个Dockerfile里打包后端,一个Nginx容器托管前端,再用docker-compose编排,适合需要频繁迁移环境或者做CI/CD的团队。

我做过一次Docker部署踩过一个大坑:MySQL容器里没有设置时区,默认是UTC,导致考勤记录在显示的时候比实际时间少8个小时。排查到最后,发现问题不是Java代码,而是MySQL容器的时区设置。解决方式是启动容器时加参数:

docker run -d \ -e MYSQL_ROOT_PASSWORD=123456 \ -e TZ=Asia/Shanghai \ -p 3306:3306 \ mysql:8.0

这个TZ环境变量必须加上。如果你已经启动了容器,也可以用docker exec进入容器修改全局时区,但下次重建容器还会丢失,不如启动参数一步到位。

6. 常见问题与避坑经验

6.1 MySQL安装与时区、SSL问题

MySQL的安装过程本身不难,但有两个问题经常让人卡住。第一个是MySQL 8.x的驱动类变成了com.mysql.cj.jdbc.Driver,不是原来的com.mysql.jdbc.Driver,SpringBoot 2.7自动配置没问题,但如果用老版本MyBatis或者手动配置数据源,就容易踩坑。第二个是连接时报SSL错误,原因是MySQL 8.x默认启用SSL,而本地开发环境的连接串没有配SSL参数。

推荐在JDBC连接串上关掉SSL并指定时区:

spring: datasource: url: jdbc:mysql://localhost:3306/attendance?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai username: root password: 123456 driver-class-name: com.mysql.cj.jdbc.Driver

这里的serverTimezone=Asia/Shanghai是必须的。如果你不指定,MySQL驱动会取系统默认时区,一旦服务器时区和中国时区不一致,LocalDateTime的读取就会出现8小时偏差,考勤时间全部错位,排查起来比代码逻辑错误更头疼。

MySQL 5.7和MySQL 8.0的安装差异也提一下。5.7没有caching_sha2_password认证插件,8.0默认用这个,很多老旧的客户端连接8.0时会报认证错误。解决办法是安装时选mysql_native_password,或者在创建用户时指定认证方式。不过用Navicat新版、IDEA新版连接8.0基本没问题,问题多出在跑老版本代码的JDBC驱动上。

6.2 MyBatis的缓存与TypeHandler

MyBatis的二级缓存是新手容易踩雷的地方。一级缓存是SqlSession级别的,同一个SqlSession内重复查询同一SQL直接走缓存,这个基本没问题。二级缓存是namespace级别的,一旦开启,多个SqlSession之间会共享缓存。

问题在于:如果表之间有关联查询,二级缓存可能导致脏数据。比如查“课程+教师姓名”这个关联结果,被缓存到CourseMapper的namespace里,此时Teacher表的数据更新了,CourseMapper的缓存并不知道,查询结果还是旧数据。这就是我在考勤系统里默认不开二级缓存的原因。什么时候适合开?纯单表查询、数据几乎不变、并发又高,比如系统配置表、字典表。其他情况,尤其是带JOIN的业务表,别开。

TypeHandler在MyBatis里的作用是将Java类型与JDBC类型互转。最典型的场景是LocalDateTime和数据库DATETIME的转换。MyBatis 3.4.5之后原生支持LocalDateTime,基本不用自己写。但如果遇到枚举类型,比如考勤状态字段status,在Java代码中是枚举,在数据库中是String,那么实现一个TypeHandler让MyBatis自动完成转换就很方便。

@MappedTypes(AttendanceStatus.class) public class AttendanceStatusTypeHandler extends BaseTypeHandler<AttendanceStatus> { @Override public void setNonNullParameter(PreparedStatement ps, int i, AttendanceStatus parameter, JdbcType jdbcType) throws SQLException { ps.setString(i, parameter.getCode()); } @Override public AttendanceStatus getNullableResult(ResultSet rs, String columnName) throws SQLException { return AttendanceStatus.fromCode(rs.getString(columnName)); } // 其他两个 getNullableResult 类似实现 }

配置到application.yml里或者通过@TableField注解指定。这个做法让代码可读性提升一个档次,状态流转的逻辑不会散落在各个if/else里。但也要注意,TypeHandler一旦定义错误,排查难度远大于普通的类型转换问题,建议在单元测试里把枚举和字符串的互转都跑一遍。

6.3 前端路由模式与刷新404

很多人前端开发一切正常,打包部署后学生访问/course/list直接白屏或者404,就是路由模式的问题。Vue Router默认是hash模式,地址栏里带#号,刷新不会404,但看起来不专业。改成history模式,地址干净漂亮,但生产环境必须配合服务端配置。

如果后端是SpringBoot的单体部署,用前面说的addViewControllers方法转发。如果后端是Nginx,在配置里加一行:

location / { try_files $uri $uri/ /index.html; }

这句话的意思是:找不到对应的静态文件时,一律返回index.html。这样前端路由的所有路径在刷新时都能正常加载。

还有一个小问题,打包后的资源路径。Vue CLI默认的publicPath是/,如果部署在服务器的根路径下没问题;如果部署在子路径,比如example.com/school/,必须把publicPath改成相对路径或者子路径前缀,否则所有js、css请求都会404。这个配置虽然在开发环境看不出来,但部署到生产环境几乎必踩。

6.4 一个真实排查案例:批量考勤统计慢的优化

有一个真实的优化经历值得分享。教师端点击“导出整学期考勤Excel”时,接口响应时间从0.5秒暴增到8秒多。排查下来发现,问题出在查询逻辑上。

最初的实现是查询每个学生的每次考勤记录,然后在Java里循环判断“这个学生是否请假”“这个学生是否缺勤”。思路没错,但实现方式是“N+1查询”:先查出课程所有学生列表,然后每个学生再查一次考勤表、再查一次请假表。一个班级50个学生,数据库就要执行150条SQL,当然慢。

优化方案是用SQL一次查出所有学生的考勤汇总,用LEFT JOIN考勤表和请假表,一次性把状态计算出来:

SELECT stu.id AS student_id, stu.name AS student_name, COUNT(att.id) AS total_classes, SUM(CASE WHEN att.status = 'LATE' THEN 1 ELSE 0 END) AS late_count, SUM(CASE WHEN att.status = 'ABSENT' THEN 1 ELSE 0 END) AS absent_count, SUM(CASE WHEN leave.id IS NOT NULL THEN 1 ELSE 0 END) AS leave_count FROM schedule_instance si JOIN student stu ON stu.class_id = #{classId} LEFT JOIN attendance_record att ON att.schedule_instance_id = si.id AND att.student_id = stu.id LEFT JOIN leave_request leave ON leave.student_id = stu.id AND leave.status = 'APPROVED' AND leave.start_time &lt; si.end_time AND leave.end_time &gt; si.start_time WHERE si.course_id = #{courseId} AND si.week_number BETWEEN #{startWeek} AND #{endWeek} GROUP BY stu.id, stu.name

这一条SQL替代了原来的150条SQL,响应时间从8秒降到了0.3秒。这个经历说明了一个普适原则:能用JOIN和聚合解决的统计问题,不要放在Java循环里做。SQL虽然复杂一些,但数据库天生就是干这个的。

做完这个优化之后,我又在班级、学院两个粒度的统计页面上复用了这套逻辑,本质都是调整WHERE条件和GROUP BY字段。所以第一版设计时把排课实例表做出来,后续写统计SQL时就特别顺。如果一开始没有这张表,这种聚合查询会复杂到让人崩溃。

我个人在实际项目里的最大体会是,考勤系统难的不是某个单独的技术点,而是数据口径的一致性。签到状态、请假状态、缺勤状态、出勤率百分比,如果每个模块各算各的,最后对账一定对不上。所以动手写代码之前,先把状态字典和统计口径定义清楚,比如“出勤率 = 正常签到次数 /(应出勤次数 - 请假次数)”,并且让所有接口都复用同一个统计SQL模板,这样后期才不会改一处崩三处。

最后再分享一个小技巧:开发阶段可以把MyBatis的SQL日志打印开起来,在application.yml里配置mybatis.configuration.log-impl=org.apache.ibatis.logging.stdout.StdOutImpl,这样每条SQL执行时都能在控制台看到完整的语句和参数。排查“查到0条数据但数据库明明有数据”这种魔幻问题的时候,这个日志能帮你把问题迅速缩小到SQL拼接逻辑上。等部署上线前再关掉,避免日志刷屏和参数泄露。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询