
大学生考勤系统这个题目几乎每年都会出现在毕业设计和课程设计里但真正能跑通、能演示、能答辩的系统并不多。我做完这套基于SpringBootVue的考勤管理系统之后最大的感受是它看起来是个常规CRUD项目实际做下来从数据库设计、接口划分到前端交互每一层都有值得打磨的细节。整套系统后端用Java SpringBoot数据库用MySQL持久层用MyBatis前端用Vue Element UI最终实现了学生签到打卡、请假申请、教师审批、出勤统计、课程管理和分角色权限控制这六块核心功能。写这篇博文是想把这套系统的设计思路和实现过程完整拆解一遍给正在做类似课题的同学、准备转行Java开发的朋友以及刚接触前后端分离思想但还没完整落地过项目的初学者提供一个能直接参考的案例。这个系统具体解决什么问题传统课堂考勤依赖纸质签到和课后人工统计老师拿到的出勤数据往往是滞后的、零散的。这套系统把签到动作挪到线上学生进入课程页面一键签到后端实时记录时间戳并自动判定正常、迟到、缺勤请假流程也走线上审批辅导员或任课教师通过后自动抵扣缺勤记录。到期末统计时按课程、按班级、按周次生成出勤率报表把以前一天才能整理完的数据压缩到几秒钟。1. 项目整体设计与技术选型1.1 为什么是SpringBoot Vue而不是其他组合先说后端。SpringBoot现在的优势不需要我多讲内嵌Tomcat、自动配置、开箱即用的starter依赖让一个Java新人避开了大量繁琐的XML配置。对比传统SSM要手动配一堆beanSpringBoot的application.yml几行配置就能把数据源、端口、日志全部搞定。而且国内Java生态对SpringBoot的接受度极高找资料、踩坑、问问题的成本都低对做毕业设计或者课程设计的学生来说这是最实际的考量。前端选Vue而不是React核心原因是上手曲线。Vue的单文件组件、双向绑定、指令系统很贴合后端开发者的思维习惯加上Element UI这套现成的组件库做后台管理类页面基本是拼积木。考勤系统本质上属于管理信息系统页面形态就是登录、表格、表单、统计图Vue Element UI天然适配这类场景。MySQL和MyBatis属于数据层的固定搭配。MySQL开源、免费、轻量大学里教学基本都围绕它展开运维资料多MyBatis作为半自动ORMSQL由开发者自己掌控遇到复杂的多表联查、分组统计能精确控制执行逻辑不像JPA那样在复杂查询上容易失控。我听不少同学说用JPA做统计报表时生成的SQL绕来绕去最后只能写原生SQLMyBatis从一开始就省掉这个烦恼。1.2 前后端分离架构的收益与代价这个项目我选择前后端完全分离前端独立工程通过axios调后端接口后端不返回页面只返回JSON数据。这样做的好处很明显——开发时可以并行推进前端用mock数据调试界面后端用Postman测接口后期系统扩展也不会互相牵制比如加一个移动端H5只需要复用后端接口即可。但代价同样存在。跨域问题、Token鉴权、接口文档维护都是前后端分离带来的额外工作量。我在开发中用了Spring Boot的CORS配置解决跨域用JWT做无状态登录认证这些在单体SSM项目里都不需要考虑。所以如果你的课题只要求能跑不打算投入精力处理这些问题前后端分离未必是省事的选择。我的建议是有答辩演示需求、想体现技术含量的选分离架构只求功能完整的可以把Vue打包后放进SpringBoot的static目录省掉跨域这一堆事。提示前后端分离项目部署到服务器上之后跨域问题经常反反复复。后端CORS配置的allowedOrigin不要写死为localhost改成动态读取配置项不然换域名或IP部署时又得改代码。1.3 技术栈版本选择与踩坑记录技术组件推荐版本说明JDK1.8不要用太高版本SpringBoot 2.x对JDK17的支持存在坑SpringBoot2.5.x稳定且教程多3.x改动大不建议新手MyBatis Starter2.2.x配合SpringBoot官方starter使用MySQL5.7 / 8.08.0要注意SSL连接和时区问题Vue2.6.x与Element UI最稳定的搭配Element UI2.15.x后台管理类组件库Node.js14打包Vue项目所需环境这里必须多说一句版本选择SpringBoot不要一上来就追最新版。我试过用SpringBoot 3.x做这个项目javax包名改成了jakarta、部分starter整合方式也变了网上大量教程和源码根本对不上排查问题的成本直线上升。SpringBoot 2.5.x配合JDK8是当前中文社区资料最密集的组合做课题讲求稳不追新。2. 数据库设计与核心表结构考勤系统的数据库设计是整个项目的基石表结构要是定得不合理后面写Service层的时候会非常痛苦。我做设计的时候反复改了三四版最后沉淀下来的是七张核心表涵盖用户、课程、考勤和请假四个维度。2.1 六张核心业务表的设计思路第一类是用户体系。这里没有把学生和教师拆成两张独立的登录表而是设计一张user用户表加上role_id字段区分角色学生和教师共用一个账号体系登录入口统一。为什么要这么设计因为考勤场景里教师和学生存在大量跨角色交互比如学生请假要找老师审批老师开课要关联学生名单统一用户表能让关联查询简单很多。学生的学号、姓名、班级信息放在student_profile表中教师职称、所属院系放在teacher_profile表中都通过user_id关联主表。第二类是教学关系。course表保存课程基本信息course_student表是课程与学生的多对多关联表记录哪个学生选了哪门课。这张表非常关键教师在创建考勤任务时签到名单就是从这张表里捞出来的。第三类是考勤业务表。attendance_record表记录每一次签到结果leave_request表保存请假申请。这两张表是系统的核心数据来源期末统计报表全靠它们。表名核心字段作用userid, username, password, role_id统一账号登录与角色控制student_profileid, user_id, student_no, name, class_name学生扩展信息teacher_profileid, user_id, name, title教师扩展信息courseid, course_name, teacher_id, begin_time, end_time课程信息course_studentid, course_id, student_id选课关联签到名单来源attendance_recordid, course_id, student_id, attendance_date, status, sign_in_time每次签到的结果流水leave_requestid, student_id, course_id, leave_type, reason, status, admin_id请假审批流第七张表本来是sys_role角色表但系统就三个角色管理员、教师、学生我用枚举在代码里处理了没有再单独建表省一次联查。如果你后续要扩展出助教、院系管理员等多角色再把它拆出来也不迟。2.2 建表SQL里的关键细节直接看最核心的考勤记录表CREATE TABLE attendance_record ( id bigint(20) NOT NULL AUTO_INCREMENT, course_id bigint(20) NOT NULL COMMENT 课程ID, student_id bigint(20) NOT NULL COMMENT 学生用户ID, attendance_date date NOT NULL COMMENT 考勤日期, status tinyint(4) NOT NULL DEFAULT 0 COMMENT 0正常 1迟到 2缺勤 3请假 4早退, sign_in_time datetime DEFAULT NULL COMMENT 签到时间, create_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_course_student_date (course_id, student_id, attendance_date), KEY idx_student_date (student_id, attendance_date) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT考勤记录表;这里有两个细节我要重点强调。第一是唯一索引 uk_course_student_date。同一门课同一个学生同一天只能有一条考勤记录这是从数据库层面兜底防止重复签到。如果没有这个约束代码里一旦出现并发请求或者前端按钮连点两次就会产生脏数据。我测试的时候故意用JMeter模拟了100个并发签到请求正是这个唯一索引挡住了绝大多数重复记录。第二是status字段的类型设计。考勤状态用tinyint存数字而不是直接存中文字符串是因为统计逻辑需要对状态做聚合计算数字枚举操作远比字符串方便而且省空间。0正常、1迟到、2缺勤、3请假、4早退这个约定在前后端要保持一致前端用一个常量映射表翻译成中文显示。2.3 索引设计的三点经验查询频率最高的是按课程查某天考勤和按学生查历史记录所以联合索引和单列索引都覆盖了这两个方向。不要给所有字段都加索引。一张表如果超过六七个索引写入性能会明显下降何况考勤系统业务量本来就不大索引够用就行。时间字段上的查询多用范围条件如果后续要做按周按月统计建议在attendance_date上单独建索引我目前用idx_student_date覆盖了大部分场景。3. 后端核心模块实现3.1 工程结构与分包规范后端代码我按controller - service - mapper - entity四层分包这是国内Java项目最常见、也最容易理解的一种组织方式。com.example.attendance ├── controller # 接口层 ├── service # 业务逻辑层 │ └── impl ├── mapper # MyBatis接口 ├── entity # 实体类 ├── common # 统一返回结果、异常、工具类 ├── config # 配置类CORS、拦截器、JWT └── AttendanceApplication.java分包规范看起来是小事实际影响非常大。我看到过不少同学把所有代码堆在一两个类里controller里直接写SQL最后项目两千行挤在一个类中排查一个Bug要翻半天。四层分包的核心价值是各层职责单一controller只做参数接收和结果返回service专注业务规则mapper只写数据访问出了问题能顺着调用链快速定位。3.2 实体类与Mapper层的编码要点实体类以AttendanceRecord为例Data public class AttendanceRecord { private Long id; private Long courseId; private Long studentId; private Date attendanceDate; private Integer status; private Date signInTime; private Date createTime; }用Lombok的Data注解省去getter/setter代码量直线下降。但要注意数据库字段是下划线命名实体类是驼峰命名必须在application.yml里开启驼峰映射mybatis: configuration: map-underscore-to-camel-case: true这个配置忘了开你会发现自己查出来的一堆字段全是null而且这种null不会报任何异常排查起来非常隐蔽。我最初调试签到列表接口时接口返回200但数据里courseId、studentId全是null整整查了一个小时才发现是这个开关没开。Mapper层我推荐用XML方式写SQL而不是注解。原因很简单复杂的动态SQL用注解拼起来完全没法看XML里可以写if、foreach还能统一管理。比如批量插入选课名单insert idbatchInsertCourseStudent insert into course_student (course_id, student_id) values foreach collectionstudentIds itemstudentId separator, (#{courseId}, #{studentId}) /foreach /insert3.3 Service层与Controller层的业务编排Service层是业务规则集中地。以学生签到为例完整流程是前端提交课程ID后端先校验该学生是否选了这门课再校验当前时间是否在课程的有效签到窗口内最后检查当天是否已签到都没问题才插入考勤记录并返回结果。if (courseStudentMapper.countByCourseAndStudent(courseId, userId) 0) { throw new BusinessException(未选修该课程无法签到); } if (todayRecord ! null) { throw new BusinessException(今天已签到请勿重复操作); } // 判定考勤状态课程开始前20分钟至开始后10分钟内签到为正常 // 超过开始时间10分钟算迟到状态判定的阈值我放在application.yml里配置而不是硬编码。原因是答辩或演示的时候老师很可能会问这个时间窗口怎么定义的用配置项能直接展示你考虑了业务参数化。正常、迟到、缺勤三种状态的边界也要定义清楚课程开始前20分钟到开始后10分钟内签到算正常超过10分钟算迟到整节课都没签到由系统自动判定缺勤。Controller层做得很简单只做参数接收、调用Service、统一包装返回RestController RequestMapping(/api/attendance) public class AttendanceController { PostMapping(/sign) public Result sign(RequestBody SignRequest request) { attendanceService.sign(request.getCourseId()); return Result.success(); } }统一返回结果类Result包含code、message、data三个字段前端axios拦截器根据code判断业务是否成功错误信息可以清晰传到前端弹窗里而不是裸抛HTTP 500。3.4 登录认证与权限控制系统用JWT做登录态管理。用户登录成功后后端生成一个token返回前端存到localStorage后续每个请求在header带上Authorization字段。后端写了一个拦截器从token里解析用户ID和角色根据接口路径前缀做放行或拦截。三个角色权限做了简单划分管理员能管理课程和用户教师能创建考勤任务、审批请假、查看所授课程统计学生只能签到、请假和查看自己的考勤记录。权限校验不复杂就是拦截器里根据role_id加一层判断。注意JWT的密钥一定要放到配置文件中不要写死在代码里。我做项目演示的时候把jwt.secret写在了常量类里被指导老师当场点了出来后来改成配置项注入这也算是一个容易忽略的安全细节。4. 前端Vue页面与交互实现4.1 前端工程组织与路由配置前端工程用Vue CLI创建目录结构如下src ├── api # 接口请求封装 ├── router # 路由配置 ├── store # Vuex状态管理 ├── views # 页面组件 │ ├── Login.vue │ ├── StudentHome.vue # 学生端首页 │ ├── StudentAttendance.vue # 学生签到页 │ ├── TeacherCourse.vue # 教师课程管理 │ ├── TeacherAttendance.vue # 教师发起签到/查看记录 │ ├── AdminUser.vue # 管理员用户管理 │ └── Statistics.vue # 统计报表 ├── components # 通用组件 └── utils # axios封装、工具函数路由方面要特别讲一下动态路由。系统按角色显示不同菜单我用router.addRoutes根据登录用户的角色动态注册路由。刚开始做的时候用了静态路由加v-if控制菜单显示结果发现学生直接改URL就能访问教师页面虽然后端也有权限校验但前端体验上很糟。改成动态路由之后未授权的路由根本不会注册前端层面的防护就做住了。4.2 Axios请求封装与登录态管理axios封装的核心是拦截器。请求拦截器统一加token响应拦截器统一处理业务code和HTTP错误service.interceptors.request.use(config { const token localStorage.getItem(token) if (token) { config.headers[Authorization] Bearer token } return config }) service.interceptors.response.use(response { const res response.data if (res.code ! 200) { this.$message.error(res.message) if (res.code 401) { router.push(/login) } return Promise.reject(new Error(res.message)) } return res.data }, error { this.$message.error(网络请求失败) return Promise.reject(error) })这段代码解决了一个实际痛点如果每个页面都单独处理token和错误提示代码会非常冗余而且容易遗漏。统一封装之后新写一个页面调用接口只需要三行代码登录失效也会自动跳回登录页。4.3 考勤页面核心交互细节学生签到页是整个系统里最直观的部分。页面加载时调用后端接口获取当前用户已选课程列表每门课程卡片上显示课程名、上课时间和一个立即签到按钮。点击签到后调接口后端判定状态返回签到成功或迟到前端用Message提示并刷新签到状态。这里有个交互细节值得说按钮点击后我直接做了pending禁用处理防止用户在接口返回前重复点击。同时签到按钮根据当天状态显示不同样式——已签到显示灰色不可点击未签到显示高亮。这些看似小的地方实际演示的时候给老师的观感完全不同。教师端发起签到页面类似选择课程、选择日期、点击开始签到后端会为该课程当天所有选课学生生成初始的缺勤记录占位等学生签到后回填状态。如果教师重复发起后端根据唯一索引会直接提示该课程当天考勤已存在避免生成重复数据。5. 考勤核心业务逻辑与统计规则5.1 签到状态判定规则判定逻辑看起来简单但边界情况特别多。我设计的规则如下时间条件状态课程开始前20分钟至开始后10分钟内签到正常课程开始10分钟后至课程结束前签到迟到课程结束后仍未签到缺勤请假审批通过请假关键在课程结束的判断。后端拿当前时间与课程表中预设的结束时间比较如果当前时间已经超过下课时间接口直接返回本次课程已结束无法签到。这个判断能挡掉一种常见漏洞学生忘签到后在下课后补签如果后端不校验时间签到记录就失去意义。签到窗口的起始时间也做了限制过早签到会被拒绝。因为有些学生会凌晨就把一整天的课都签了这种刷考勤行为必须从服务端杜绝。前端隐藏按钮、做倒计时只能约束正常人真正的防线在后端校验。5.2 请假审批的业务闭环请假流程是学生提交请假申请选择课程、日期、理由、请假类型教师端看到待审批列表通过后该学生当天的考勤记录状态变为请假。请假状态在统计中既不算出勤也不算缺勤作为独立状态参与报表计算。这里有一个细节请假通过后如果学生当天已经签到过了怎么办我在审批接口里做了判断如果当天已有签到记录且状态是正常或迟到则不允许审批通过直接提示该学生当天已有签到记录无法审批请假。这个规则是试运行阶段被真实数据打出来的坑。一开始没考虑这个问题结果老师审批通过后统计报表里出勤和请假同时存在数据互相矛盾花了半天才排查出原因。5.3 出勤统计与报表实现统计模块是整个系统最让同学头疼的部分因为涉及多表联查和分组聚合。我的实现思路是统计接口接收课程ID和时间范围SQL先关联course_student获得选课名单再左连接attendance_record取每天的状态最后用SUM(CASE WHEN)做状态计数。SELECT s.student_no, s.name, COALESCE(SUM(CASE WHEN r.status 0 THEN 1 ELSE 0 END), 0) AS normal_count, COALESCE(SUM(CASE WHEN r.status 1 THEN 1 ELSE 0 END), 0) AS late_count, COALESCE(SUM(CASE WHEN r.status 2 THEN 1 ELSE 0 END), 0) AS absent_count, COALESCE(SUM(CASE WHEN r.status 3 THEN 1 ELSE 0 END), 0) AS leave_count, COUNT(*) AS total_days FROM course_student cs JOIN student_profile s ON cs.student_id s.user_id LEFT JOIN attendance_record r ON r.student_id s.user_id AND r.course_id cs.course_id WHERE cs.course_id #{courseId} GROUP BY s.user_id这条SQL是统计报表的核心也是最容易出现Null问题的地方——左连接后如果某学生某天没有考勤记录SUM函数会返回Null而非0所以我在Mapper层用COALESCE做了兜底把Null转成0。前端拿到统计数据后用ECharts画出勤率折线图和状态分布饼图整个统计页面就很完整了。6. 常见问题与排查技巧实录做这个项目踩的坑不少我把高频问题整理成一张表给后面做的人省点时间。问题描述根本原因解决方案接口返回200但字段全是null没开MyBatis驼峰映射mybatis.configuration.map-underscore-to-camel-casetrueMySQL连接报SSL错误MySQL 8.0默认开启SSL本地证书校验失败url加useSSLfalseserverTimezoneAsia/Shanghai前端请求后端跨域报错前后端分离部署在不同端口后端配置CORSallowedOrigin读取配置文件Vue打包后访问页面404history路由刷新时资源路径不对改用hash模式或后端配置fallback中文乱码数据库表字符集不是utf8mb4建库建表统一utf8mb4url加characterEncodingutf8JWT过期后页面无响应前端未统一处理401响应拦截器统一拦截401并跳转登录页6.1 MySQL 8.0连接报错的完整排查这个问题真的值得单独说。我用的是MySQL 8.0SpringBoot连接时第一次启动直接报SSL connection error。问题根源是MySQL 8.0默认开启SSL本地环境没有配置证书JDBC驱动在校验时失败。解决方案是在jdbc url后面追加参数url: jdbc:mysql://localhost:3306/attendance?useSSLfalseserverTimezoneAsia/ShanghaicharacterEncodingutf8allowPublicKeyRetrievaltrueuseSSLfalse关掉SSL验证即可。allowPublicKeyRetrievaltrue是MySQL 8.0配合caching_sha2_password认证方式需要的不加也会报错。serverTimezone设置成Asia/Shanghai可以顺带解决The server time zone value这个时区报错如果没设时区启动时经常会冒出一串乱码式的时间错误。6.2 MyBatis动态SQL拼接的坑用XML写动态SQL时最容易犯的错误是where条件为空导致SQL语法错误。比如根据课程ID和日期筛选考勤记录如果课程ID为空时直接拼出来的SQL是WHERE AND attendance_date?数据库直接报语法错误。我推荐使用 标签它会自动去掉第一个多余的AND。select idselectByCondition resultTypeAttendanceRecord select * from attendance_record where if testcourseId ! null and course_id #{courseId} /if if testattendanceDate ! null and attendance_date #{attendanceDate} /if /where /select还有一个容易被忽略的坑resultType里的实体类字段和数据库列不一致时MyBatis不会报错只会默默返回null。开了驼峰映射后要检查列名和实体字段的对应关系尤其是create_time这类标准字段拼写错一个字母非常难发现。6.3 前端路由模式的避坑建议Vue Router默认是hash模式URL上带#号但不影响功能。如果为了美观改成history模式部署到服务器后刷新子页面会404因为服务器没有配置前端路由的fallback。对考勤系统这个项目我的建议是直接用hash模式省心且演示时没人会在意URL里多一个#号。如果你确实想用history模式后端要做对应的转发处理对新手来说不值得折腾。6.4 时间处理的一致性问题后端用java.util.Date接收前端传的时间存到数据库datetime字段但前端JavaScript的Date对象在序列化时默认转成ISO字符串带时区T和Z如果不做处理会出现时间偏移8小时的经典问题。我最终的方案是前端所有日期参数统一用字符串格式yyyy-MM-dd HH:mm:ss传参后端用JsonFormat注解标注接收格式数据库存的就是干净的时间字符串不依赖默认序列化。这个方案笨但有效状态判定、统计分组都不再受时区干扰。6.5 项目打包与部署运行建议本地开发时后端和前端分别起服务后端8080、前端8081通过CORS通信。正式部署时我推荐两种方案一是前后端各自部署前端打包后的dist目录扔到Nginx后端打成jar包用java -jar运行二是把前端打包后的dist目录拷贝到SpringBoot的static目录下合并成一个jar。我做演示主要用第二种一个jar包到处跑演示环境不用装Node.js。最后部署时用的命令整理如下# 后端打包 mvn clean package -DskipTests java -jar target/attendance.jar # 前端打包 npm run build # 将 dist 目录中的文件拷贝到后端 src/main/resources/static 下重新打包这里有一个隐藏坑前端打包后拷贝到static目录如果路由用了history模式首页能打开但刷新子页面会404所以再次强调用hash模式。另外后端打包时如果遇到依赖冲突优先检查spring-boot-maven-plugin是否配置了repackage目标不然打出来的jar没有内嵌Tomcat直接java -jar会报no main manifest attribute。做完这套系统再回头看我对考勤这类管理项目的理解已经完全不一样了。它表面上是增删改查但真正决定项目质量的是那些边界情况的处理重复签到怎么拦截、请假和签到数据冲突怎么办、统计时Null怎么兜底、跨域部署怎么配置。这些问题都没写在任何教程的标题里但每一个都在实际运行中暴露过、修改过。给正在做类似题目的朋友一个建议先把数据库设计的唯一约束和索引想清楚再动手写代码这比后期补各种if判断要省太多时间。第一次做前后端分离的同学也请记住跨域、token、时间格式化这三件事一定要提前规划好它们是这类项目里隐藏的工期杀手。