平时帮别人评审项目或带新人练手时,我见过太多在“师生健康信息管理”这类系统上翻车的案例:不是前端按钮乱放、接口裸奔,就是把所有学生数据一股脑返回给任意登录用户。表面看“管理系统”三个字很普通,可一旦碰学校场景,角色权限、每日打卡、体温异常追踪、统计报表这些需求扯在一起,逻辑量完全不输一个中型的业务系统。
我手上的这套源码正好是 SpringBoot2 + Vue3 + MyBatis-Plus + MySQL8.0 的组合,前后端分离,内置了完整的健康打卡、健康档案、异常上报、返校申请、数据统计等模块,附带数据库脚本和部署文档。这篇文章我会把项目的需求拆解、技术选型、表设计、关键接口逻辑、本地跑通步骤和常见坑一次性讲透,适合正在做毕设、课设,或者打算在公司内部快速搭一套健康管理后台的开发者参考。
1. 项目概述与需求拆解
1.1 系统解决的问题与目标场景
师生健康信息管理,本质上要解决的是三个问题:一是健康数据怎么低成本地“收上来”,二是异常情况怎么第一时间“传出去”,三是积累的数据怎么变成管理者能看懂的结果。
纸质登记或者班级群接龙的方式,信息分散在多个表格和聊天记录里,辅导员想统计当天谁没打卡,得人工翻记录;学校管理层想看全院发热趋势,调整不了一手数据。这个项目把学生、教师、辅导员、管理员统一到同一套平台里,学生端完成每日测温打卡和健康情况填报,教师端能看到本人及所带班级的基础数据,辅导员端审核异常、处理返校申请,管理员端维护基础档案并查看全校统计报表。
从源码学习角度来看,这个项目比较典型的价值在于:它既有复杂的角色权限体系,又有高频写入的打卡业务,还有聚合查询的统计模块。三个难点互相独立又彼此关联,很适合作为理解前后端分离项目落地的样板。
1.2 核心角色与功能矩阵
不需要过度设计太多角色,但学校场景里至少要分四类:管理员、辅导员、教师、学生。角色不同,关注的数据范围不同,能操作的菜单也不同。
| 角色 | 核心功能 | 数据可见范围 |
|---|---|---|
| 管理员 | 用户管理、班级管理、学院管理、全局统计、系统配置 | 全校数据,可导出 |
| 辅导员 | 审核异常上报、审批返校申请、查看所带班级打卡情况 | 自己所带班级 |
| 教师 | 查看本人健康状况、提交行程相关报备、查看授课班统计 | 本人及授课班级部分数据 |
| 学生 | 每日健康打卡、体温上报、返校申请、查看个人健康档案 | 仅本人数据 |
权限矩阵一旦定好,前后端的菜单和按钮就能跟着梳理出来。很多项目在这步偷懒,导致后端接口没有做数据隔离,学生传个 classId 就把他人的班级数据查出来了。这个项目在后端查询逻辑里强制拼接当前用户的数据范围,前端隐藏按钮只是交互层面的第一道防线,真正可控的还是接口层。
1.3 关键业务流程设计
健康管理系统的核心流程不是复杂的审批链,而是“采集—判断—流转—统计”这条主线。
学生每天进入系统后填写当日体温、健康状况、是否接触重点观察人员、当前所在地等信息。后端收到数据后,先判断体温是否超过 37.3℃ 或健康状态是否异常,若判断为异常,系统会自动把记录同步到辅导员的工作台的“待处理异常”列表。辅导员查看后可以标记为“已关注”或“上报上级”,同时可以发起与学生相关的线下核实流程。管理员在数据看板中则主要看当日的打卡率、异常人数、近 7 天体温趋势等聚合指标。
另一个容易忽略的需求是“补卡”。学校实际使用中,总有学生早上漏填,下午回来补卡。如果不做设计,简单限制“当天时间过了就不能填”会让辅导员被大量私聊淹没。所以系统要支持在允许时间窗口内补录,并且补录记录里要标清“补卡”还是“正常”,统计报表里也要有对应口径,避免重复计数。
2. 技术栈选型与方案对比
2.1 后端为什么选 SpringBoot2
看到这个项目的第一反应可能是:现在新项目都上 Spring Boot3 了,为什么还要选 2?这个问题我在实际开发中纠结过,最后保留了 SpringBoot2 的选型是有具体原因的。
最直接的原因是 JDK 版本兼容性。很多学校的机房服务器、公司内部老运维环境还停在 JDK8,而 Spring Boot3 最低要求 JDK17,迁移成本高,哪怕本地开发没问题,到部署阶段也会遇到阻力。SpringBoot2.7 正好处于一个非常成熟的生命周期边界:既能享受 2.x 系列全部生态积累,又离 Jakarta EE 迁移很远,各种 starter 组件的兼容性文档都齐全,遇到问题搜解决方案也好搜。
另一个理由是这套项目要演示的并非“最新特性”,而是快速搭建业务系统的能力。SpringBoot2 的自动配置、内嵌 Tomcat、Starter 机制,配合 MyBatis-Plus 之后,写一个单表 CRUD 接口几乎不需要模板代码。对于中小型管理系统来说,稳定、熟练、能快速交付,远比追求框架版本新更重要。
2.2 前端为什么选 Vue3
Vue3 现在的普及度已经很高了,选它不是赶时髦,而是考虑组合式 API 带来的逻辑复用能力。健康管理系统里有大量“管理员查看列表数据并导出 Excel”的重复页面,如果按 Vue2 的 Options API 写,逻辑会散落在 data、methods、watch 里,页面一多就难维护。组合式 API 可以把“分页查询 + 条件筛选 + 导出”封装成一个 hooks 函数,每个页面引用后只需要配置查询参数和表格列,开发效率提升非常明显。
配套工具链我用的是 Vite + Vue Router + Pinia + Element Plus。Vite 的冷启动速度对比 Webpack 是碾压级的,开发时改一行代码浏览器几乎瞬时刷新;Pinia 相比 Vuex 的 API 更简洁,没有 mutations 那一层,写起来更顺手;Element Plus 的表格、表单、日期选择器在管理后台场景下非常成熟,基本不需要自己造轮子。
2.3 为什么 ORM 选 MyBatis-Plus、数据库选 MySQL 8.0
MyBatis-Plus 在这个项目里帮了大忙。它内置了 BaseMapper,单表增删改查拿过来就能用;分页插件和逻辑删除也是开箱即用。相比原生 MyBatis 要手写大量的 mapper XML,MyBatis-Plus 能省掉至少三分之一的基础 SQL。而相比 JPA/Hibernate,MyBatis-Plus 对 SQL 的控制粒度更细,和团队里“习惯手写 SQL”的老开发合作时,代码 review 也更容易通过。
MySQL8.0 的升级点主要体现在窗口函数和 JSON 支持上。做全校统计报表时,窗口函数可以直接在 SQL 里算环比和排名;如果后续要冗余一些动态表单字段,JSON 类型也比硬加字段列更灵活。配合 8.0 的 utf8mb4 字符集,中文和 emoji 都不会出现问题。唯一要注意的是连接驱动和时区参数,这个后面实操章节我会专门讲。
2.4 什么时候不需要引入 Redis 和 MQ
很多同学一看到“打卡”就想上 Redis 做分布式锁,看到“统计”就想上消息队列削峰。我得说:这个项目不引入这些中间件,是为了贴合业务真实负载。
师生健康管理系统的日活虽然可能有几万人,但打卡时间集中在早上 7 点到 9 点,平均 QPS 并不高,真正需要解决的其实是“同一用户当天重复提交”的幂等问题。这个通过数据库的唯一索引就能解决,完全不需要引入 Redis 锁增加部署复杂度。
统计报表虽然要聚合大量记录,但也可以用 MySQL 的定时任务加汇总表预计算。引入 MQ 意味着多一个中间件要运维,多一套消息失败重试逻辑,对源码学习型项目来说是负担。所以这个项目的技术栈“精简”,不是能力不足,而是按需取舍。我个人建议先把核心运行链路做扎实,每天定时生成统计结果,真到了秒级实时统计再上 Redis 和 MQ 也不迟。
3. 核心模块设计与关键实现
3.1 数据库表结构与设计要点
数据库设计我拆成两大类:基础资料表和业务流水表。
基础资料表包括:系统用户表、学生扩展信息表、班级表、学院表、角色菜单表。业务流水表包括:健康打卡记录表、异常上报记录表、返校申请记录表、通知公告表。
健康打卡记录表是最核心的一张表,我的字段设计大概是这样:
| 字段名 | 类型 | 说明 |
|---|---|---|
| id | bigint | 主键 |
| user_id | bigint | 学生用户ID |
| record_date | date | 打卡日期 |
| temperature | decimal(4,1) | 体温 |
| health_status | tinyint | 健康状态 1正常 2异常 |
| is_contact | tinyint | 是否接触重点观察对象 |
| location | varchar | 当前所在地 |
| is_remedy | tinyint | 是否补卡 |
| create_time | datetime | 创建时间 |
| update_time | datetime | 更新时间 |
这里有两个很重要的细节。第一,record_date 和 user_id 必须建联合唯一索引,这是防重复打卡的底层防线。第二,is_remedy 字段一定要加,否则统计打卡率时没办法区分“正常打卡”和“补卡”,报表口径就不严谨。
学生扩展信息表则单独建,不要把学号、班级、专业、宿舍号全部塞进用户表。这样设计的好处是:用户表只关心登录认证,学生属性可以独立扩展。如果某天要加“是否接种疫苗”字段,只需要在学生扩展表加列,不会动到认证核心表。
3.2 每日打卡与幂等控制
打卡接口是整个系统写入频率最高的接口,设计重点就两个:幂等控制和状态判断。
接口逻辑层面,我先在 Service 里按 user_id 和今天日期查一次记录,存在就直接返回“今日已打卡”,不存在才继续插入。但这只解决了单机情况下的重复请求问题,如果前端双击按钮、或者用脚本并发重放,两次请求前后脚穿过查询,还是可能插入两条记录。所以数据库层面的唯一索引兜底必不可少。
插入的时候可以这样处理:
HealthRecord record = new HealthRecord(); record.setUserId(currentUserId); record.setRecordDate(LocalDate.now()); record.setTemperature(param.getTemperature()); record.setHealthStatus(param.getHealthStatus()); record.setIsContact(param.getIsContact()); record.setLocation(param.getLocation()); record.setIsRemedy(Boolean.TRUE.equals(param.getIsRemedy()) ? 1 : 0); try { healthRecordMapper.insert(record); } catch (DuplicateKeyException e) { throw new BizException("今日已经完成健康打卡,请勿重复提交"); }正常业务判断 + 数据库唯一约束,双保险下来基本可以放心。
还有一个细节是体温格式化。前端传上来的体温可能是 36.55,数据库字段是 decimal(4,1),直接用数字类型接收会精度丢失,我建议后端统一用 BigDecimal 类型接收并在入参校验里加上范围限制,比如 35.0 到 42.0 之间,超范围直接拒绝,这能挡掉一部分无效数据。
3.3 权限认证与数据权限隔离
系统的权限设计分成“认证”和“授权”两层。认证用 JWT Token 解决,用户登录成功后后端签发 token,前端每次请求在 Authorization 头里带上,后端通过拦截器解析并校验身份。JWT 本身无状态,适合前后端分离架构,部署时不需要额外做 Session 同步。
关键是在业务接口里不能只做“登录了没有”的判断,还要做“这个数据他能不能看”的判断。比如辅导员查看学生列表,后端查询时必须自动带上它的班级范围,不能接收前端传一个 classId 就按这个条件查。我会在自定义注解里定义数据权限级别,在 Service 层统一处理。
学生查询个人档案时更要注意:接口直接通过 token 里的用户ID去查,绝不相信请求参数里的 ID。实际开发中我看到过学生把接口里传的 userId 改成别人的,直接把别人健康档案拿到手,这种就是典型的越权漏洞。所以这个项目所有涉及个人数据的查询,都必须从当前登录态的 Principal 对象中取身份,而不是从请求体取。
角色控制我采用的是 RBAC 模型:用户绑定角色,角色绑定菜单权限和按钮权限。后端在鉴权时判断角色编码,前端根据用户权限列表动态渲染路由和按钮。前后端同时做校验,但权衡量以后端为准。
3.4 统计报表与导出实现
统计报表是一个管理系统的门面,不能靠 Excel 手工拉数。这个模块我按“明细落库 + 定时汇总 + 接口查询 + 前端图表”四层结构实现。
打卡率统计的核心 SQL 是统计每个班级应打卡人数和实际打卡人数:
SELECT c.class_name, COUNT(DISTINCT s.user_id) AS total_students, COUNT(DISTINCT CASE WHEN hr.id IS NOT NULL THEN s.user_id END) AS checked_students FROM student_info s LEFT JOIN class_info c ON s.class_id = c.id LEFT JOIN health_record hr ON hr.user_id = s.user_id AND hr.record_date = #{queryDate} GROUP BY c.id, c.class_name体温异常趋势统计则利用 MySQL8.0 的窗口函数,比如计算每个班级连续七天的异常人数变化,甚至可以用 LAG 函数算环比,比在 Java 内存里处理要高效得多。
图表部分前端用 ECharts 渲染,打卡率用折线图,体温分布用散点图,异常占比用饼图。ECharts 本身没有 Vue 生态的强耦合,直接封装一个图表组件就行,数据格式从后端统一返回成 ECharts 需要的[{ name: '班级A', value: 98.5 }]结构,前端不需要做过多的数据转换。
导出功能用的是 EasyExcel,标题行、数据行、时间格式都可以通过注解控制。大批量导出时要注意内存问题,建议用 EasyExcel 的流式写法,边查询边写,而不是一次性加载全量数据到内存再写。
4. 本地环境搭建与源码编译运行
4.1 开发环境准备
开始之前先把环境对齐,这个项目依赖的东西不多,但每个版本都有讲究:
| 依赖 | 推荐版本 | 备注 |
|---|---|---|
| JDK | 8 或 11 | 不建议用 17,部分老依赖可能不兼容 |
| Maven | 3.6.3+ | 用于后端依赖管理 |
| Node.js | 16 或 18 | Vite 5 要求 Node 18+,16 也能跑 |
| MySQL | 8.0.x | 推荐 8.0.30+ |
| IDE | IDEA 2022+ | 前端用 VSCode 也行 |
确认版本没问题后,先创建一个名为 health_db 的数据库,再导入项目里提供的 SQL 脚本。初始化数据里通常会带上一个管理员账号和几个测试角色,方便第一次登录后快速观察效果。
4.2 后端配置文件解读
后端核心配置在application.yml,第一步是数据库连接,这一块最容易出问题。
spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/health_db?useUnicode=true&characterEncoding=utf8mb4&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true username: root password: your_passwordurl 里三个参数必须解释清楚:serverTimezone=Asia/Shanghai是为了避免 MySQL 时区导致日期差 8 小时;useSSL=false是本地开发不需要 SSL;allowPublicKeyRetrieval=true是 MySQL8.0 使用 caching_sha2_password 认证时必需的,不加会报 Public Key Retrieval is not allowed。
MyBatis-Plus 的配置里,逻辑删除和分页插件是重中之重:
mybatis-plus: global-config: db-config: logic-delete-field: deleted logic-delete-value: 1 logic-not-delete-value: 0 configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImplmap-underscore-to-camel-case这个开关建议打开,数据库字段 create_time 才能自动映射到 createTime,省掉一大堆@TableField注解。
JWT 配置放在自定义配置类里,主要包含密钥、过期时间、请求头名称。密钥不要写在代码里,通过 application.yml 的@ConfigurationProperties注入,这样换环境时不用改代码。
4.3 前端工程与联调配置
前端项目初始化之后,第一步要处理的是跨域问题。我推荐直接利用 Vite 的 proxy 配置,而不是在后端写@CrossOrigin,因为 proxy 方案只在开发环境生效、生产环境交给 Nginx 转发,代码里更干净。
// vite.config.js export default defineConfig({ server: { port: 3000, proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true, rewrite: (path) => path.replace(/^\/api/, '') } } } })Axios 封装时拦截请求头携带 token,同时统一处理响应异常。登录接口返回后,把 token 存入 localStorage,同时把用户信息和权限列表存入 Pinia。路由守卫里判断“是否登录”“当前路由是否需要权限”“用户是否有该权限”三个问题,满足条件才放行,否则跳转登录页。
还有一个很容易忽略的点:前端明确定义所有请求路径都带/api前缀,后端 Controller 的 RequestMapping 则不带这个前缀。这样做的好处是,以后如果要对接口做统一网关转发,只需要改 Nginx 一个地方。
4.4 启动顺序与验收清单
很多初学者习惯“写完代码直接启动”,结果一团乱麻。正确的启动顺序应该是:
- 先启动 MySQL,确认数据库和初始化数据已导入。
- 启动后端 SpringBoot 应用,看到“Started Application”日志,再去访问
http://localhost:8080的健康检查接口。 - 用 IDEA 的 HTTP Client 或 Postman 先测后端登录接口,拿到 token 后再测其他带鉴权的接口。后端接口验证通过前,不要碰前端。
- 启动前端 Vite 服务,浏览器访问
http://localhost:3000,首次登录管理员账号,再逐个角色走一遍菜单。
验收时可以按这个清单快速过:管理员能否正常管理用户和班级?学生能否完成当日打卡并且重复打卡被拦截?辅导员能否看到自己班级的打卡统计?异常记录从学生填报到辅导员工作台是否实时展示?统计报表数字是否和数据库里手动查询的结果一致?凡是验收过程中发现不符预期的地方,优先看后端日志,因为大部分问题都出在接口逻辑而不是前端。
5. 常见问题与避坑实录
5.1 数据库连接相关报错
我遇到最多的问题基本都集中在数据库连接上,这里列三个高频报错。
| 报错信息 | 原因 | 解决方案 |
|---|---|---|
| The server time zone value 'Öйú±ê׼ʱ¼ä' is unrecognized | 数据库时区未设置 | url 加serverTimezone=Asia/Shanghai |
| Public Key Retrieval is not allowed | MySQL8 认证插件问题 | url 加allowPublicKeyRetrieval=true |
| Unknown database 'health_db' | 数据库没创建 | 先执行CREATE DATABASE并导入 SQL |
另一个版本坑是驱动版本。SpringBoot2.7 默认管理的 mysql-connector-java 版本是 8.0.x,如果手动引入了 5.x 的驱动,启动时大概率报加载驱动类错误。不需要自己手动指定驱动坐标,除非有特殊需求。
5.2 MyBatis-Plus 使用中的典型坑
MyBatis-Plus 的坑和配置习惯关系很大。
第一个是分页不生效。很多人只引入了分页插件依赖,但没创建 MybatisPlusInterceptor Bean,导致 Page 对象传入后查出来的还是全部数据。必须在配置类里补上:
@Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor(); interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); return interceptor; }第二个是自动填充失效。如果表里有 create_time 和 update_time,建议用@TableField(fill = FieldFill.INSERT)配合 MetaObjectHandler 统一填充,避免每次 insert 前手动 set 时间。很多项目这里没配置,结果列表页时间全是 null,排查半天才发现是填充器没注册。
第三个是逻辑删除字段。加了@TableLogic之后,MyBatis-Plus 会自动在 SQL 拼接AND deleted = 0,但前提是全局配置里也要对应声明逻辑删除字段名。如果业务里还要真删除某些关联数据,需要单独写原生 SQL,否则会被逻辑删除干扰。
5.3 前端登录与跨域问题
前端 401 和跨域问题看着是两件事,根源经常是一个。
如果 Axios 请求能发到后端,但返回 401,先检查请求头里 Authorization 的值是否带了Bearer前缀,很多后端框架要求这个前缀,少一个就会解析失败。其次是看 token 过期时间,JWT 过期之后后端会拒绝,前端响应拦截器应该捕获这个状态,跳转登录页并清除本地缓存。
如果是 CORS 报错,先用浏览器 Network 面板确认请求是否真的发到了目标端口。如果发到了 8080,说明 proxy 没生效,检查 vite.config.js 有没有被正确加载;如果请求地址是 3000 端口且能看到代理日志,那多半是后端返回头里没允许跨域。最省事的办法就是开发环境全程走 Vite proxy,后端不用开启跨域支持,生产环境交给 Nginx,两边都干净。
5.4 并发打卡与数据一致性问题
最后一个要提的是高并发场景下的隐蔽坑。我曾经压过一个原型,模拟 200 个学生同一秒打卡,发现重复数据出现了好几条。代码层面明明先做了查询判断,但并发下查询和插入之间是有时间窗的,这就是我前面强调要建唯一索引的原因。
如果你不希望直接依赖数据库报错来拦截,也可以在插入前用 Redis 的 SETNX 做短时间锁,但那样要引入 Redis 运维成本。在这个项目里我的结论很明确:先用数据库唯一索引保底,业务代码里用 try-catch 捕获 DuplicateKeyException 并给出友好提示,够用且稳定。
还有一个小细节是关于日期的。打卡的“今天”必须后端统一用 LocalDate.now() 生成,不能信前端传的 date。否则前端所在时区、用户手改系统时间都会导致查询不到今日记录或重复生成记录。这个看似低级的坑,实际项目里出问题的概率非常高。
最后再分享一点我的个人经验:拿到这种源码项目,第一件事不要急着启动,而是先打开数据库脚本把表结构看一遍,理清楚 user、student_info、health_record 之间的关联关系,再由表结构反推接口和页面,整个系统在你脑子里会清晰很多。等你把登录、打卡、异常处理这条主链路跑通,后面再玩权限扩展、报表导出,都是顺水推舟的事。希望这篇文章能让你少踩几个我踩过的坑,把项目真正吃透。