
一个做了一半就搁置的课设项目前段时间翻出来重新收拾了一下把技术栈换成了SpringBoot2 Vue3 MyBatis-Plus MySQL8.0顺手补齐了前后端分离的整套代码和设计文档。今天就拿这个大学生考勤系统当例子把这套东西从架构设计到落地联调的完整过程捋一遍包括我在实际开发中踩进去的坑和最后怎么绕出来的希望对正在做类似管理系统或者准备答辩的同学有点实际帮助。这个项目本身不复杂就是典型的管理系统三件套学生信息管理、课程考勤记录、统计报表展示。但麻雀虽小五脏俱全登录鉴权、角色权限、CRUD接口、前后端联调、数据可视化这些该有的都有了而且新版前端换成了Vite Vue3 Element Plus界面交互比之前那套老模板舒服太多。如果你正在纠结毕业设计或者课程设计选什么方向这种“功能完整但不过度复杂、技术栈新但不冷门”的项目其实是很好的练手对象——既能把主流框架串起来跑一遍又不会把自己绕死在奇奇怪怪的工程化问题上。1. 项目整体设计与技术选型思路1.1 为什么选这套技术栈先说我为什么最后定的是SpringBoot2而不是SpringBoot3。现在网上不少教程上来就推3.x但实际做课设或者企业里维护老项目2.x依然是绝对主力。SpringBoot 2.7.x版本对应的是JDK8/11而SpringBoot3强绑定JDK17很多实验室和教学环境用的还是JDK8强行上3.x会导致环境配置成本直接翻倍。另外SpringBoot2的生态资料极多遇到问题搜解决方案基本一找一个准对于没有太多排错经验的学生党来说这一点极其重要。本项目用的是2.7.x系列稳定且兼容性最好。Vue3方面我直接用Vite作为构建工具而不是Webpack。Vite的dev server启动速度快到离谱几秒钟就能看到页面效果开发体验和Webpack完全不在一个时代。当然这不全是Vite的优点也因为它底层用了原生ES Module浏览器直接解析模块而不用先打包所以冷启动才会那么快。Vue3的组合式APIComposition API写起来逻辑聚合性比Options API好太多比如考勤记录页面的筛选条件和表格数据可以写在同一个setup上下文里不用像以前那样分散在data、methods、computed一堆选项里。MyBatis-Plus选它的理由非常直白单表CRUD真的不用写SQL。它内置的BaseMapper提供了insert、deleteById、selectById、selectPage这些常用方法配合条件构造器QueryWrapper/LambdaQueryWrapper大部分操作一行代码搞定。对于考勤系统这种业务逻辑不算复杂但表数量不少的项目MyBatis-Plus能省掉大量重复的XML文件。不过这里必须提醒一句MP虽然方便但复杂多表联查该写SQL还是要写后面我详细说。MySQL8.0基本上是当前的事实标准了。8.0相比5.7在窗口函数、公用表表达式、默认字符集utf8mb4这些方面都有大幅改进。特别是utf8mb4如果你还在用5.7及以下的默认latin1存emoji或者生僻字直接乱码警告。另外8.0的caching_sha2_password默认认证插件会导致部分老版本驱动连接失败这个问题我见过太多次了等会儿排查篇单独讲。1.2 系统架构与模块划分整体架构是标准的前后端分离方案前端Vue3应用通过HTTP调用后端RESTful API后端按分层架构拆成controller、service、mapper三层数据存储在MySQL中。前端单独跑在Vite dev server上开发环境或打包成静态文件由Nginx托管生产环境后端打包成jar直接跑。从业务模块来看系统分为四个核心部分系统管理登录认证、JWT Token签发与解析、密码加密、当前用户信息获取。权限部分按角色拆成管理员、教师、学生三种控制粒度到接口级别。学生管理学生信息的增删改查、批量导入、按院系/专业/班级筛选。这块实际是基础数据维护属于所有管理系统的地基。考勤管理课程表管理、考勤记录新增、考勤状态标记出勤/迟到/请假/缺勤、考勤列表多条件查询、考勤统计。数据统计按课程/班级维度统计出勤率、迟到率、缺勤率用柱状图或饼图展示。前端用ECharts渲染图表。模块划分这样拆好处是职责单一、相互依赖少。比如学生管理模块不会直接操作考勤记录表而是通过考勤服务间接关联后面对权限和业务逻辑做扩展就不会牵一发动全身。2. 数据库设计与核心表结构2.1 表结构设计思路数据库设计这一步决定了后面所有业务代码的写法我建议你先画ER图把实体关系捋顺再建表不要上来就CREATE TABLE。这个考勤系统涉及的核心实体有用户User、学生Student、课程Course、考勤记录Attendance、请假申请Leave它们之间的逻辑关系是用户与角色一个用户属于一种角色管理员/教师/学生用用户表的role字段区分不用独立的角色表因为角色只有三种且没有权限细粒度扩展需求。学生与用户学生账号和用户数据是一对一关系但考虑到有些教师也是管理员所以单独建了学生表存学号、姓名、班级等教务数据用户表只存登录凭证和角色。课程与考勤记录一门课程可以有多条考勤记录通过course_id关联。考勤记录与学生一条考勤记录对应一个学生的一次上课情况通过student_id关联。这个表结构的主线就是“谁、在哪个课、哪次课、什么状态”其余字段都是围绕这条主线补充的。数据库命名我统一用下划线风格字段用snake_case因为MySQL在Linux上对大小写敏感混用大小写容易给自己埋坑。2.2 核心表结构定义学生表student的字段大概是这样CREATE TABLE student ( id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT 主键, student_no VARCHAR(20) NOT NULL UNIQUE COMMENT 学号, name VARCHAR(50) NOT NULL COMMENT 姓名, gender TINYINT DEFAULT 0 COMMENT 性别 0未知 1男 2女, grade VARCHAR(10) COMMENT 年级如2022级, major VARCHAR(50) COMMENT 专业, class_name VARCHAR(50) COMMENT 班级, phone VARCHAR(20) COMMENT 手机号, email VARCHAR(100) COMMENT 邮箱, user_id BIGINT COMMENT 关联用户表id, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, deleted TINYINT DEFAULT 0 COMMENT 逻辑删除标记 ) COMMENT 学生信息表;注意几个细节。第一是加了deleted字段做逻辑删除因为学生数据往往关联大量考勤记录物理删除会把历史数据连带搞没逻辑删除用MyBatis-Plus的TableLogic注解就能自动过滤。第二是update_time用了ON UPDATE CURRENT_TIMESTAMP这样每次更新记录时字段会自动刷新不用在代码里手动维护。第三是student_no设置了唯一索引避免批量导入时重复学号刷进去。考勤记录表attendance长这样CREATE TABLE attendance ( id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT 主键, course_id BIGINT NOT NULL COMMENT 课程id, student_id BIGINT NOT NULL COMMENT 学生id, attendance_date DATE NOT NULL COMMENT 考勤日期, status TINYINT DEFAULT 0 COMMENT 状态 0未考勤 1出勤 2迟到 3请假 4缺勤, remark VARCHAR(255) COMMENT 备注, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uk_course_student_date (course_id, student_id, attendance_date) ) COMMENT 考勤记录表;这里的联合唯一索引是关键。它保证了一门课同一个学生在同一天只能有一条考勤记录从数据库层面杜绝了重复提交。如果没有这层约束代码里即使做了判断也还是可能因为并发请求导致重复数据入库。2.3 为什么要分表而不塞进一张表可能有同学会想一个考勤系统数据量不大把用户、学生、考勤信息全塞一张表行不行技术上不是不行但维护性和扩展性会很差。比如学生改了专业你要是存的是冗余字段就要同步改所有考勤记录如果要给班级增加一个辅导员的联系方式你需要动整张主表。分表真正的收益在于每个表的字段只描述自身业务表与表用外键约束或逻辑关联结合起来。这样开发的时候改学生信息不会影响考勤模块统计的时候用JOIN把三张表拉起来也不复杂。数据库范式是大学课程里反复强调的东西实际项目按3NF去设计基本不会出大问题顶多为了查询性能在某些报表字段上做适度冗余。考勤系统规模小3NF完全够用。3. 后端核心实现与关键接口3.1 JWT登录鉴权的实现登录是几乎所有系统的第一道门槛。这个项目用的方案是无状态JWT用户登录成功后后端签发一个Token返给前端前端把Token存在localStorage里后续每次请求在请求头里带上Authorization: Bearer token后端通过拦截器统一解析校验。用JWT而不是传统的HttpSession是因为前后端分离后前端可能部署在Nginx、后端在另一台服务器Session跨域跨机器共享非常麻烦而JWT把用户身份信息加密放在Token里后端不保存状态天然适合水平扩展。JWT工具类主要包含三个方法生成Token、解析Token、校验Token。生成时把用户的id、角色、登录时间塞进Payload再设置一个过期时间一般是2到24小时。我用的是jjwt库核心代码大致这么写public String generateToken(Long userId, String role) { Date now new Date(); Date expireDate new Date(now.getTime() EXPIRE * 1000); return Jwts.builder() .setSubject(String.valueOf(userId)) .claim(role, role) .setIssuedAt(now) .setExpiration(expireDate) .signWith(SignatureAlgorithm.HS256, SECRET_KEY) .compact(); }注意SECRET_KEY用Base64编码后的字符串不要明文放在代码里。实际项目可以从配置文件读取例如jwt.secret这样分发代码和部署环境时可以分开配置。另外HS256是对称加密如果有多套服务共享同一份Token密钥管理要格外小心。拦截器这边实现HandlerInterceptor在preHandle方法里取出请求头中的Token调用JWT工具类解析。解析失败则直接返回401状态码不进入Controller。放行规则用注册拦截器时配置路径来搞定登录接口和静态资源不需要鉴权。3.2 考勤API的参数设计与分页查询考勤记录查询接口算是这个项目里最有代表性的一块。我的接口设计是GET请求参数包含了pageNum、pageSize、courseId、studentId、status、attendanceDate。分页本身是MyBatis-Plus的强项直接用Page对象配合LambdaQueryWrapper就行public IPageAttendanceVO getAttendancePage(int pageNum, int pageSize, AttendanceQuery query) { PageAttendance page new Page(pageNum, pageSize); LambdaQueryWrapperAttendance wrapper new LambdaQueryWrapper(); wrapper.eq(query.getCourseId() ! null, Attendance::getCourseId, query.getCourseId()) .eq(query.getStudentId() ! null, Attendance::getStudentId, query.getStudentId()) .eq(query.getStatus() ! null, Attendance::getStatus, query.getStatus()) .eq(StringUtils.hasText(query.getAttendanceDate()), Attendance::getAttendanceDate, query.getAttendanceDate()) .orderByDesc(Attendance::getCreateTime); IPageAttendance attendancePage attendanceMapper.selectPage(page, wrapper); return convertToVO(attendancePage); }这种写法有个细节值得说说eq方法的第一个参数是boolean条件只有条件为真时才把该过滤条件加入到SQL中。这样前端传不传某个筛选参数都不需要写一堆if判断代码看着清爽很多。这个模式是MyBatis-Plus条件构造器最实用的地方。查询结果直接返回实体类会有点问题因为关联的课程名称、学生姓名不在attendance表里。我一般会专门做一个VO对象在service层把课程名和学生名补充进去。你可以用循环查表也可以用一条联查SQL小数据量场景循环查表问题不大但要提防N1问题。考勤系统数据量顶天几千条不敏感如果你做的是高并发系统老老实实用SQL JOIN或者MyBatis-Plus的关联查询插件。3.3 考勤统计的SQL写法统计报表是考勤系统的门面功能需要按课程、班级、状态分组统计数据。常见需求比如某个班的出勤率、迟到率、缺勤率某个课程一段时间内的考勤趋势。我最常用的是SQL里用CASE WHEN配合SUM做行转列统计这样一次查询就能汇总全部状态数据SELECT course_id, COUNT(*) AS total_count, SUM(CASE WHEN status 1 THEN 1 ELSE 0 END) AS present_count, SUM(CASE WHEN status 2 THEN 1 ELSE 0 END) AS late_count, SUM(CASE WHEN status 3 THEN 1 ELSE 0 END) AS leave_count, SUM(CASE WHEN status 4 THEN 1 ELSE 0 END) AS absent_count FROM attendance WHERE course_id #{courseId} GROUP BY course_id为什么不用多条COUNT查询因为聚合类SQL一次扫描数据集能得出所有分组统计效率高代码也简洁。这里面的CASE WHEN其实就是在SQL层面做条件计数把行的维度转换成列的维度。如果你还需要按时间维度看趋势加一个日期条件再按attendance_date分组就可以了。前端ECharts的柱状图数据格式是一组对象数组后端只要把查询结果转成ListMapString, Object或者专门写个StatVO返回前端直接绑定。第二个核心统计接口是按班级维度汇总。这里需要JOIN student表和course表因为attendance表不直接存班级字段。用JOIN有以下代价attendance表的数据量如果非常大会影响查询性能此时可以冗余一个class_name字段在考勤表里但更新班级名时需要同步更新考勤表。这就是典型的空间换时间权衡。在考勤系统这个规模下写JOIN是最合适的不用为了性能牺牲数据一致性。4. 前端Vue3实现与页面细节4.1 前端工程结构与路由设计前端用的是Vite搭建的Vue3项目目录结构基本是标准的src/api放接口请求封装src/router放路由配置src/store放状态管理用的Piniasrc/views放页面组件src/components放通用组件。路由这一块要重点说权限控制。Vue Router有全局前置守卫我在这里做了登录校验和角色判断。简单来说路由meta里定义了需要的角色列表每次跳转前检查Token是否存在再检查当前用户的角色是否在meta.roles数组里不匹配就跳403页面。请求封装用的是axios实例baseURL统一指向/api请求拦截器从localStorage取Token塞进header响应拦截器统一处理错误码。这里有个非常常见的坑当你写POST请求时如果Content-Type没设置对后端接收到的参数可能全是null。axios默认的Content-Type是application/json如果你后端的接口接收的是表单类型参数就会出问题。我当时统一约定后端全部接收JSON格式这样请求拦截器只要写一遍。4.2 考勤打卡页面的交互逻辑考勤打卡或者说考勤录入页面的前端逻辑是教师选择授课课程系统自动带出该课程关联的学生列表显示今日考勤的按钮组出勤、迟到、请假、缺勤教师逐一点击或者批量设置最后点击提交把所有记录批量插入数据库。这一块有个细节是“待考勤”的默认状态。新增考勤记录时status为0表示未考勤前端列表初始化的时候把每个学生显示为“未考勤”的灰色标签等教师点击后再变更。提交时把整个数组传给后端后端循环判断哪些学生还没有记录有更新、无新增。前端表格用的是Element Plus的el-table列里嵌套el-radio-group来让教师快速切换状态。诱导一个更好的体验是用el-table的行点击或者按钮组勾选但为了更快批量操作我最后选择了每行放一组单选按钮。如果你做的是移动端适配版本可以改成滑动操作或手势选择。4.3 ECharts统计图表的数据接入统计页面用ECharts的柱状图和饼图展示数据。Vue3里集成ECharts有两种方式直接装echarts库用ref拿到DOM节点后在onMounted里init或者用封装好的vue-echarts组件。我推荐直接引原生库少一层依赖就少一份踩坑的可能。动态图表的核心是watch或者watchEffect监听数据变化后setOption。因为图表初始化只在onMounted执行一次后面接口数据回来如果不主动更新图表就永远是空的。所以我会先把统计接口的数据用reactive对象存起来等数据加载完调用chart.setOption把series里data替换成最新的。一个经验之谈ECharts的柱状图和饼图在数据量较小时渲染差异不大但如果统计维度超过20个饼图会非常难看这时候优先用柱状图而不是饼图。考勤系统按班级统计一般就是几个到十几个班级柱状图展示效果最好并且可以用渐变色区分出勤和缺勤。5. 部署联调与常见问题排查5.1 本地开发环境搭建的完整步骤第一次从零开始跑这个项目的完整顺序我整理一下照着做基本能一次跑通。第一步装好JDK8或11、Maven 3.6、Node.js 16、MySQL 8.0。MySQL版本要注意如果你机器上已经装了5.7需要额外处理默认字符集和认证插件的问题。第二步创建数据库。项目根目录的sql文件夹里有两个文件schema.sql是建表语句data.sql是初始数据。执行顺序是先建库、再执行schema、最后执行data。这里有个小细节初始化数据里包含默认的管理员账号学号admin、密码admin123密码是BCrypt加密后的值不是明文用来首次登录。第三步配置后端application.yml。改三处数据库连接地址、数据库用户名密码、JWT密钥。如果MySQL和项目跑在同一台机器上地址就是localhost:3306如果你用的是云数据库或者Docker里的MySQL地址要改成对应的host和端口。第四步启动后端。在项目根目录执行mvn spring-boot:run看到Started Application in xx seconds就说明启动成功默认端口8080。第五步启动前端。进入frontend目录npm install安装依赖然后npm run dev启动Vite默认端口5173。浏览器访问localhost:5173就能看到登录页。第六步验证登录。用初始账号登录后检查学生列表是否加载出来如果没有数据基本是数据库连接配置问题或者是初始化SQL没执行成功。5.2 联调阶段常见的跨域问题前后端分离开发时跨域是最折磨人的问题。前端跑在5173端口后端跑在8080端口浏览器直接发Ajax请求会因为跨域限制被拦截。解决办法是在后端配置CORS过滤器允许前端地址跨域访问。SpringBoot里可以用WebMvcConfigurer来全局配置Configuration public class CorsConfig implements WebMvcConfigurer { Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping(/**) .allowedOriginPatterns(*) .allowedMethods(GET, POST, PUT, DELETE, OPTIONS) .allowedHeaders(*) .allowCredentials(true) .maxAge(3600); } }这里特别要注意allowedOriginPatterns和allowCredentials的组合。如果你设置了allowCredentials(true)allowedOrigins不能直接用必须用allowedOriginPatterns()否则浏览器会报CORS错误。这个细节坑了我一下午当时日志里一直显示请求被CORS策略阻止怎么查都查不出原因后来才发现是这个配置冲突。另一种思路是开发环境用Vite的proxy代理把前端请求转发到后端这样浏览器看到的请求是同源的。两种方式各有优劣。CORS方案部署时不需要额外配置但每次请求都会多一次OPTIONS预检Proxy方案只在开发环境好用打包上线后还是要用Nginx处理跨域或者直接同域部署。5.3 我踩过的高频坑和排查思路先列一个高频问题速查表都是我实际开发过程中遇到的启动报错Access denied for user rootlocalhost数据库用户名密码不对或者root账号不允许远程连接。检查application.yml里的配置MySQL里执行SELECT user, host FROM mysql.user确认账号。启动报错Unknown database attendance_db数据库还没创建执行CREATE DATABASE attendance_db DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;。连接超时Communications link failureMySQL没启动或者端口不是3306。Linux下用systemctl status mysqld看服务状态。中文乱码建库时字符集没指定utf8mb4或者连接URL少了characterEncodingutf8参数。前端403 Forbidden可能是Token过期了或者该账号没有访问对应接口的权限。打开浏览器DevTools的Network面板看响应体的错误信息。页面白屏且控制台报错Mismatching childNodes前端页面路由配置问题检查是否有重复的path或者重定向死循环。MySQL8.0的认证插件问题我单独拎出来说。8.x默认的认证插件是caching_sha2_password但很多老版本驱动用的是mysql_native_password连接时会直接报Authentication plugin caching_sha2_password cannot be loaded。解决办法有两种一是升级MySQL驱动版本到8.0以上二是把用户的认证插件改回mysql_native_passwordALTER USER rootlocalhost IDENTIFIED WITH mysql_native_password BY 你的密码; FLUSH PRIVILEGES;但我建议优先升级驱动而不是改数据库配置。因为mysql_native_password本身就是旧方案安全性不如caching_sha2_password只是驱动不兼容才会出问题。如果项目部署到生产环境用旧认证插件会有安全隐患。5.4 打包部署的细节后端打包直接用Maven的package命令生成一个可执行的jar包。如果pom.xml里spring-boot-maven-plugin配置没问题打包出来的jar直接用java -jar就能跑。注意JDK版本要跟编译环境一致否则会出现UnsupportedClassVersionError。前端打包是npm run build生成dist目录。上线时把dist目录下的文件扔到Nginx的html目录或者配置Nginx root指向这个目录。如果是前后端分开部署Nginx需要配置反向代理把/api开头的请求转发给后端的8080端口location /api/ { proxy_pass http://127.0.0.1:8080; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; }这一步配置完前端的axios baseURL要改成/api不能再是http://localhost:8080/api。如果写死绝对地址部署到服务器后还要改代码重新构建非常麻烦。还有个部署时容易忽略的点JWT的SECRET_KEY在开发环境和生产环境必须不能一样。如果你把代码部署到公网服务器攻击者可以通过分析前端js逆向出密钥然后伪造任意用户Token直接调用管理接口。这个风险很多人容易忽略但实际危害非常大。至少把所有硬编码密钥提取到环境变量或者外置配置文件。6. 项目扩展方向与个人心得这个考勤系统做完之后我觉得可以继续扩展的方向至少有两个。第一个是增加课程表管理与教学班功能。目前的课程表其实是一个简化版本只有课程名称和任课教师如果你想做得更贴近真实教务系统应该增加学期、星期几、第几节课、教学楼、教室号这些字段。有了课程表之后考勤打卡就可以直接按当天课程自动带出学生名单而不是教师手动选择课程。第二个是加入消息通知功能。比如学生被标记为缺勤后自动发送站内信或者邮件通知请假申请审批通过后自动通知教师。这些在SpringBoot里都很容易实现但能给系统的完整度带来显著提升。按照我个人的实际体会做这种管理系统项目最大的收获不在于把CRUD写得多熟练而在于搞清楚前后端职责边界、联调时的协作方式、以及数据流转的正确性。很多学生把后端写完就以为万事大吉等到联调阶段才发现接口字段对齐、状态码约定、异常处理这些前期的设计如此重要。代码写得好与坏通过运行结果能看出一部分但项目工程的魅力在于把一堆细节拼起来的整个过程。如果你正在用这个考勤系统做课设或者练手建议不要只满足于“跑起来”把登录流程从申请Token到拦截到刷新过期的完整链路走一遍把统计接口的SQL执行计划拿出来看看收获会比抄一遍代码大得多。