SpringBoot+Vue校园疫情防控系统:权限与状态机工程实战

发布时间:2026/10/7 2:58:27
SpringBoot+Vue校园疫情防控系统:权限与状态机工程实战 简介这份资源是基于Spring Boot与Vue的校园疫情防控系统完整项目包面向计算机相关专业的毕设学生及需要实战练手的Java Web开发者。项目采用前后端分离架构后端以Java配合Spring Boot框架搭建前端使用Vue.js实现数据驱动的单页界面运行依赖JDK1.8、Tomcat7与MySQL5.7适合作为校园健康信息管理类课题的参考方案。压缩包共424个文件约30.18MB涵盖101个Java源码、60个Vue组件、161个svg图标、19个js脚本及xml、yml配置、sql数据库脚本、doc功能文档等源码、建库脚本与说明文档齐备目录结构清晰。目前已有2669人学习下载。读者可据此掌握前后端分离开发流程、接口联调与数据库表设计思路并直接在此基础上扩展定制项目经严格调试可稳定运行兼具实用价值与教学意义。1. 校园疫情防控系统一个被低估的权限与状态机工程校园疫情防控系统听起来像是个过时的题目但真正动手做过的人都知道它本质上是一套多角色权限 健康状态流转 审批工作流的组合体比很多电商 demo 更能锻炼工程能力。基于 springbootvue 的校园疫情防控系统核心要解决的是学生每日健康打卡、离校返校申请审批、异常状态上报与追踪、管理员数据看板这四件事。它适合正在找毕设方向的学生、想练手前后端分离的中级开发者以及需要快速搭一套内部健康管理工具的团队。很多人以为这系统就是几张 CRUD 表结果一上手就卡在谁能改谁的状态审批流怎么串打卡时间窗口怎么卡这些玄学问题上。这篇笔记就按我实际落地的顺序把选型、表结构、接口、前端路由和踩坑一次讲透。2. 技术选型与工程骨架为什么是 SpringBoot Vue 而不是别的2.1 后端选 SpringBoot 的三个现实理由选 SpringBoot 不是因为它新而是因为它在这个场景下省事。校园疫情防控系统的业务逻辑集中在权限校验、状态流转和定时任务上SpringBoot 的自动配置能让你少写大量 XML把精力放在业务上。具体来说第一权限体系直接复用 Spring Security 或 Sa-Token。系统里有学生、辅导员、院系管理员、校管理员四类角色每类角色能看到的菜单和能调的接口完全不同。SpringBoot 生态里成熟的权限方案多不用自己造轮子。第二定时任务处理打卡窗口。健康打卡通常有固定时间窗口比如每天 6:00-12:00SpringBoot 的Scheduled注解几行代码就能搞定配合数据库记录打卡状态即可。第三状态机式的审批流。离校申请从待提交→待辅导员审批→待院系审批→通过/驳回这种流转用 SpringBoot 的 Service 层配合枚举状态就能清晰表达不需要引入重型工作流引擎。提示如果你的 SpringBoot 版本太高比如 3.x注意 JDK 最低要求是 17很多学校的实验环境还停留在 JDK 8选版本前先确认部署环境。2.2 前端选 Vue 的落地考量Vue 在这个项目里的价值是组件复用和路由守卫。学生端、管理端共用一套 UI 组件库Element Plus 或 Ant Design Vue通过 vue 路由区分角色入口。动态路由是关键登录后根据后端返回的角色前端动态生成可访问的路由表避免学生手动输 URL 进管理页。vue 安装及环境配置这一步我一般用 Vite 而不是老旧的 vue-cli启动快、配置少。核心依赖就几个vue-router管路由、pinia管状态、axios管请求、element-plus管 UI。# 用 Vite 创建 Vue 项目我一般这么起 npm create vitelatest campus-health-front -- --template vue cd campus-health-front npm install npm install vue-router pinia axios element-plus npm run dev这段命令的逻辑先用 Vite 脚手架生成 Vue 3 项目骨架再装四个核心依赖。vue-router负责页面跳转和路由守卫pinia替代 Vuex 做状态管理更轻axios封装请求拦截器统一加 tokenelement-plus提供表格、表单、弹窗等组件。参数上--template vue指定用 JavaScript 而非 TypeScript如果你团队熟悉 TS 可以换成vue-ts。2.3 前后端分离的目录结构约定工程骨架定下来后目录结构要提前约定好否则后期接口对不上。我一般这么分层级后端SpringBoot前端Vue入口Application.javamain.js配置config/跨域、拦截器vite.config.js业务controller/service/mapperviews/components路由RequestMappingrouter/index.js状态数据库 Redisstores/pinia后端按controller → service → mapper三层走前端按views页面和components复用组件分。接口统一前缀/api方便 Nginx 反向代理时区分前后端。3. 数据库设计与核心表把状态和权限落到字段上3.1 五张核心表撑起整个系统校园疫情防控系统的表不用多但每张表的字段要想清楚。我落地时用了这五张-- 用户表所有角色共用用 role 字段区分 CREATE TABLE sys_user ( id BIGINT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(50) UNIQUE NOT NULL, password VARCHAR(100) NOT NULL, -- BCrypt 加密存储 real_name VARCHAR(50), role TINYINT NOT NULL, -- 1学生 2辅导员 3院系 4校级 dept_id BIGINT, -- 所属院系用于数据隔离 phone VARCHAR(20), create_time DATETIME DEFAULT NOW() ); -- 健康打卡表每人每天一条 CREATE TABLE health_record ( id BIGINT PRIMARY KEY AUTO_INCREMENT, user_id BIGINT NOT NULL, record_date DATE NOT NULL, temperature DECIMAL(3,1), -- 体温保留一位小数 health_status TINYINT, -- 0正常 1异常 2疑似 location VARCHAR(100), -- 当前所在地 remark VARCHAR(255), create_time DATETIME DEFAULT NOW(), UNIQUE KEY uk_user_date (user_id, record_date) -- 防止重复打卡 ); -- 离校/返校申请表 CREATE TABLE apply_record ( id BIGINT PRIMARY KEY AUTO_INCREMENT, user_id BIGINT NOT NULL, apply_type TINYINT, -- 1离校 2返校 reason VARCHAR(255), start_date DATE, end_date DATE, status TINYINT DEFAULT 0, -- 0待审 1通过 2驳回 current_approver BIGINT, -- 当前审批人 create_time DATETIME DEFAULT NOW() ); -- 审批流水表记录每一步谁批的 CREATE TABLE approve_log ( id BIGINT PRIMARY KEY AUTO_INCREMENT, apply_id BIGINT NOT NULL, approver_id BIGINT, action TINYINT, -- 1通过 2驳回 comment VARCHAR(255), approve_time DATETIME DEFAULT NOW() ); -- 异常上报表 CREATE TABLE abnormal_report ( id BIGINT PRIMARY KEY AUTO_INCREMENT, user_id BIGINT NOT NULL, report_type TINYINT, -- 1发热 2密接 3其他 description VARCHAR(500), handle_status TINYINT DEFAULT 0, -- 0未处理 1处理中 2已完结 create_time DATETIME DEFAULT NOW() );这几张表的设计要点health_record用UNIQUE KEY (user_id, record_date)从数据库层面杜绝重复打卡比在代码里查一遍再插入可靠得多。apply_record的current_approver字段是关键它让审批流可以接力——辅导员批完把 current_approver 改成院系管理员而不是靠代码硬编码流转顺序。approve_log单独存流水方便追溯谁在什么时候驳回了什么。3.2 状态字段的枚举约定状态字段用数字还是字符串我踩过坑早期用字符串pending结果前端传参大小写不一致导致查询失败。后来统一用 TINYINT在 Java 里定义枚举对应。public enum ApplyStatus { PENDING(0, 待审批), APPROVED(1, 已通过), REJECTED(2, 已驳回); private final int code; private final String desc; // 构造、getter 省略 }这样前端传status0查待审后端用枚举转换避免魔法数字散落各处。参数说明code存数据库desc给前端展示两边通过接口返回的字典映射对齐。3.3 数据隔离院系管理员只能看本院数据多角色系统最容易翻车的地方是数据越权。院系管理员登录后只能看到本院的打卡和申请。实现方式是在 Mapper 查询时强制拼dept_id条件而不是靠前端过滤。// Service 层根据当前登录用户的 deptId 过滤 public ListHealthRecordVO listByDept(Long deptId, LocalDate date) { LambdaQueryWrapperHealthRecord wrapper new LambdaQueryWrapper(); wrapper.eq(HealthRecord::getRecordDate, date); if (deptId ! null) { // 校级管理员 deptId 为 null看全部 wrapper.inSql(HealthRecord::getUserId, SELECT id FROM sys_user WHERE dept_id deptId); } return healthRecordMapper.selectList(wrapper); }逻辑说明校级管理员deptId传 null不加过滤看全部院系管理员传自己的 deptId通过子查询限定只查本院用户。参数上inSql里的子查询要确保dept_id有索引否则数据量大时慢查询。这里用字符串拼接是简化写法生产环境应该用参数化查询防注入。4. 打卡与审批接口实现从 Controller 到状态流转4.1 健康打卡接口时间窗口与重复提交打卡接口看着简单实际有两个坑时间窗口校验和重复提交。我一般这么写PostMapping(/health/clock) public Result clockIn(RequestBody HealthClockDTO dto) { // 1. 校验打卡时间窗口6:00 - 12:00 LocalTime now LocalTime.now(); if (now.isBefore(LocalTime.of(6, 0)) || now.isAfter(LocalTime.of(12, 0))) { return Result.fail(当前不在打卡时间窗口内); } // 2. 校验今天是否已打卡 Long userId SecurityUtils.getUserId(); HealthRecord exist healthRecordMapper.selectOne( new LambdaQueryWrapperHealthRecord() .eq(HealthRecord::getUserId, userId) .eq(HealthRecord::getRecordDate, LocalDate.now())); if (exist ! null) { return Result.fail(今日已打卡请勿重复提交); } // 3. 保存记录 HealthRecord record new HealthRecord(); record.setUserId(userId); record.setRecordDate(LocalDate.now()); record.setTemperature(dto.getTemperature()); record.setHealthStatus(dto.getHealthStatus()); record.setLocation(dto.getLocation()); healthRecordMapper.insert(record); return Result.success(打卡成功); }逻辑说明先卡时间窗口再查重最后落库。参数上HealthClockDTO只暴露前端该传的字段体温、状态、位置userId和recordDate由后端从 token 和系统时间取防止前端伪造。注意第 2 步的查重和数据库唯一索引是双保险——并发下两个请求同时通过查重唯一索引会拦住第二个此时捕获DuplicateKeyException返回友好提示。4.2 离校申请审批状态机怎么串审批流的核心是当前审批人字段的接力。学生提交后current_approver 指向其辅导员辅导员通过后指向院系管理员院系通过后状态置为已通过。Transactional public void approve(Long applyId, Integer action, String comment) { ApplyRecord apply applyRecordMapper.selectById(applyId); // 1. 校验当前用户是否有权审批 Long currentUserId SecurityUtils.getUserId(); if (!currentUserId.equals(apply.getCurrentApprover())) { throw new BizException(您不是当前审批人); } // 2. 写审批流水 ApproveLog log new ApproveLog(); log.setApplyId(applyId); log.setApproverId(currentUserId); log.setAction(action); log.setComment(comment); approveLogMapper.insert(log); // 3. 驳回直接结束通过则流转到下一级 if (action 2) { apply.setStatus(2); apply.setCurrentApprover(null); } else { Long nextApprover findNextApprover(apply); if (nextApprover null) { apply.setStatus(1); // 没有下一级审批完成 } else { apply.setCurrentApprover(nextApprover); } } applyRecordMapper.updateById(apply); }逻辑说明整个方法加Transactional保证流水写入和状态更新要么都成功要么都回滚。findNextApprover根据当前审批人的角色找上一级比如辅导员role2的下一级是本院系管理员role3。参数上action用 1/2 区分通过和驳回驳回时清空 currentApprover 表示流程终止。这里的关键是权限校验必须在服务端做前端隐藏按钮只是体验优化不能当安全措施。4.3 前端动态路由登录后按角色生成菜单vue 路由这块动态路由是必做的。登录后拿到角色前端过滤路由表// router/index.js const asyncRoutes [ { path: /student, component: Layout, meta: { roles: [1] }, children: [{ path: clock, component: () import(/views/student/Clock.vue) }] }, { path: /admin, component: Layout, meta: { roles: [3, 4] }, children: [{ path: dashboard, component: () import(/views/admin/Dashboard.vue) }] } ]; // 登录后根据角色过滤 function filterRoutes(routes, role) { return routes.filter(r { if (r.meta r.meta.roles) { return r.meta.roles.includes(role); } return true; }); }逻辑说明asyncRoutes里每个路由用meta.roles标注可访问角色登录后调用filterRoutes过滤再用router.addRoute动态挂载。参数上roles数组对应后端的角色码。这样学生即使手动输/admin/dashboard也进不去因为路由根本没注册。配合路由守卫beforeEach检查 token双重保险。5. 避坑与排查那些让我加班到凌晨的问题5.1 跨域配置写了还是报 CORS 错误现象前端请求后端接口浏览器控制台报Access-Control-Allow-Origin缺失但后端明明配了CrossOrigin。原因跨域配置加在了 Controller 上但请求被拦截器如登录校验提前拦截并返回了 401拦截器的响应没带跨域头。或者前端 axios 带了自定义 header如 token触发了预检请求 OPTIONS而 OPTIONS 请求没被放行。解决跨域配置统一放在全局WebMvcConfigurer里并且拦截器要放行 OPTIONS 请求。Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping(/**) .allowedOriginPatterns(*) .allowedMethods(GET, POST, PUT, DELETE, OPTIONS) .allowCredentials(true) .maxAge(3600); } // 拦截器里放行 OPTIONS if (request.getMethod().equals(OPTIONS)) return true;5.2 打卡时间用服务器时间还是数据库时间现象本地测试打卡正常部署到服务器后时间窗口判断错乱明明在窗口内却提示不在。原因服务器时区是 UTCLocalTime.now()取的是 UTC 时间比北京时间晚 8 小时。解决启动时统一设置时区或在 JVM 参数里加-Duser.timezoneAsia/Shanghai。数据库连接串也要带时区MySQL 的serverTimezoneAsia/Shanghai。我一般两处都配避免玄学问题。5.3 审批流并发导致重复审批现象辅导员手快点了两次通过结果申请被流转了两次直接跳到院系又跳到完成。原因两次请求几乎同时到达都通过了当前审批人校验然后各自执行流转。解决在更新时加乐观锁或状态条件更新。UPDATE apply_record SET status #{status}, current_approver #{next} WHERE id #{id} AND current_approver #{currentUserId}判断影响行数如果为 0 说明已被别人改过返回该申请已被处理。这比加分布式锁轻量得多。5.4 前端打包后放进 SpringBoot 静态资源访问 404现象vue 打包出的dist放进resources/static访问首页正常但刷新子路由如/admin/dashboard报 404。原因vue 是单页应用路由由前端接管但刷新时浏览器直接向后端请求/admin/dashboard后端没这个路径。解决后端加一个 fallback 配置把所有非 API 请求转发到index.html。Configuration public class WebConfig implements WebMvcConfigurer { Override public void addResourceHandlers(ResourceHandlerRegistry registry) { registry.addResourceHandler(/**) .addResourceLocations(classpath:/static/); } // 配合 Controller 里的 RequestMapping(/) 返回 index.html }或者用 Nginx 的try_files $uri $uri/ /index.html生产环境更推荐后者。5.5 体温字段用 float 导致精度丢失现象前端传 36.5数据库存进去变成 36.499999。原因用了FLOAT类型浮点数精度问题。解决体温用DECIMAL(3,1)Java 侧用BigDecimal接收别用float或double。这个坑在健康类系统里尤其要注意体温数据不能有误差。6. 进阶技巧用状态机枚举 定时任务把系统做活系统能跑起来只是及格真正让它好用得在细节上做文章。分享两个我后来加上的技巧。第一个把审批流抽象成配置表。早期我把辅导员→院系→校级的流转顺序硬编码在findNextApprover里后来发现不同学院的审批层级不一样改代码太痛苦。改成配置表后流转规则可配字段含义示例apply_type申请类型1离校from_role当前角色2辅导员to_role下一级角色3院系need_approve是否需要审批1是这样新增一种申请类型或调整层级改数据不改代码。代价是查询多一次但审批是低频操作完全可接受。第二个用定时任务自动标记异常。每天打卡窗口结束后跑一个定时任务把当天没打卡的学生标记为未打卡并给辅导员推送提醒。Scheduled(cron 0 0 12 * * ?) // 每天中午12点执行 public void markAbsent() { LocalDate today LocalDate.now(); // 查出今天没打卡的学生 ListLong absentIds sysUserMapper.findStudentsWithoutRecord(today); for (Long userId : absentIds) { // 插入异常记录或发通知 abnormalReportMapper.insert(buildAbsentReport(userId, today)); } }cron表达式0 0 12 * * ?表示每天 12:00:00 触发。参数上?用于日期字段表示不指定。这个任务要注意幂等——如果服务重启导致任务重跑不能重复插入异常记录所以插入前先查当天是否已有记录。验证方法我一般写个简单的单元测试模拟学生打卡→辅导员审批→院系审批全流程断言最终状态和流水条数。这样改代码时心里有底不怕改坏状态机。Test public void testFullApproveFlow() { // 学生提交申请 Long applyId applyService.submit(studentId, dto); // 辅导员审批通过 applyService.approve(applyId, 1, 同意); // 院系审批通过 applyService.approve(applyId, 1, 同意); // 断言最终状态为已通过 ApplyRecord result applyRecordMapper.selectById(applyId); assertEquals(1, result.getStatus()); // 断言流水有两条 assertEquals(2, approveLogMapper.selectCount( new LambdaQueryWrapperApproveLog().eq(ApproveLog::getApplyId, applyId))); }这个测试跑通基本能保证审批流的主干逻辑没问题。血泪经验是状态机相关的代码测试覆盖率一定要高因为线上出问题往往就是某个状态没考虑到。最后说个习惯——我每次改完状态流转逻辑都会手动在数据库里把apply_record的status和current_approver字段打印出来看一眼确认和预期一致。这个后悔药动作帮我拦下过好几次逻辑漏洞。希望帮到你。本文还有配套的精品资源点击获取