做管理系统的项目很多时候不是技术难,而是业务乱、角色多、数据散。疫情类系统的难点更特殊:使用频率高、上报时间集中、数据准确性要求高、还要应对各种临时统计需求。这篇文章把我做过的这套基于SpringBoot+Vue+MyBatis架构、MySQL数据库的高校疫情防控管理系统的核心设计思路和实操过程完整拆出来,从需求梳理、表结构设计到前后端关键实现,再到上线后排查过的问题,一次性讲清楚。
1. 项目定位与需求拆解
1.1 这个系统到底解决什么问题
高校疫情防控系统的核心不是“上报体温”这一个动作,而是围绕学生健康数据建立一套完整的管理闭环:学生日常健康上报、出入校登记、异常情况预警、辅导员批量处理、校级数据汇总统计。我们当时接到的需求看似简单,等真正梳理完才发现,涉及的角色至少有五类:学生、辅导员、院系管理员、校医院、校级领导。每类角色眼里的“系统”都是不一样的东西。
学生在乎的是“快”——打开页面,填体温、传绿码(其实就是上传一个状态码)、点提交,十秒内完成;辅导员在乎的是“准”——班里谁没上报、谁体温异常,一眼能看到;校级领导在乎的是“全”——全校今天上报率多少、异常人数多少、趋势怎么走。一台系统要同时满足这几类完全不同的使用习惯,就必须在功能设计上做明确分层,不能简单堆菜单。
还有个容易忽略的点:这类系统要应对的是“高并发集中提交”场景。比如早上八点到九点是上课前的高峰,几千人同时提交,如果表结构或接口设计不合理,很容易出现慢查询甚至超时。当时我们做压测就遇到过类似问题,后面细说。
1.2 为什么选这套技术栈而不是别的
SpringBoot + Vue + MyBatis + MySQL这个组合乍看没有什么新奇,但它在这个场景下有几个很实在的优点:
第一,SpringBoot让后端开发效率很高。不用像老Spring项目那样配置一堆XML,内嵌Tomcat启动也方便,打包成jar就能跑。对于这种需要频繁迭代的校内系统,改完功能重新打包部署的成本很低。
第二,Vue做后台管理界面非常合适。项目用Vue 2(带Element UI)或Vue 3(带Element Plus)都可以,组件化开发让页面复用率很高。比如学生列表、上报记录列表这些页面,结构高度相似,抽象成公共组件后,开发速度会快很多。
第三,MyBatis的关系映射灵活,适合写复杂统计SQL。这类系统的统计报表非常多:学院上报率、班级异常数、每日趋势,每张报表的SQL差异都很大。MyBatis可以让你直接控制SQL语句,而不是像JPA那样在实体关系映射里绕圈子。我们实际做下来,复杂查询用MyBatis明显更顺手。
第四,MySQL足够承载校园级别数据量。一个万人规模的高校,日报表数据量一天产生一万多条,一年累计几百万条,配上合适的索引和分表策略,MySQL完全扛得住。没必要一上来就上重型中间件,轻量可靠才是校内系统的首选。
提示:技术选型不是越新越好,而是要看团队熟悉度和运维成本。校内系统通常由教务处或信息中心的老师维护,技术栈越常见,后续接手成本越低。
2. 系统架构与核心功能设计
2.1 前后端分离下的模块划分
整个系统采用标准的前后端分离架构:前端是Vue工程,跑在Nginx上;后端是SpringBoot的jar包,跑在服务器上;数据库用MySQL。前后端通过RESTful接口通信,使用JSON传递数据。
后端按业务模块分包,而不是按技术层分包,这是我们实践下来比较舒服的组织方式:
com.xxx.health │ ├── common // 公共类:统一返回结果、异常处理、工具类 ├── config // 配置类:跨域、拦截器、定时任务 ├── controller // 控制层:接收请求 ├── service // 业务层:处理核心逻辑 ├── mapper // 数据访问层:MyBatis接口 ├── entity // 实体类 ├── dto // 入参/出参对象 └── vo // 视图对象:给前端的数据结构这样分包的好处是,一个模块(比如健康上报)从Controller到Mapper的所有代码都聚在一起,改需求的时候不用在多个技术包之间跳来跳去。不同模块之间通过Service接口交互,尽量避免一个模块直接调用另一个模块的Mapper。
前端部分按路由划分页面组:登录注册页、学生端工作台、辅导员管理页、校级数据大屏、系统管理页。每块功能独立成视图文件夹,配上对应的API请求文件。
有点要注意的是,做这类多角色系统,前端路由和后端权限要双重控制。前端控制只是提升体验——咱不显示无权限的菜单,但真正的数据安全必须靠后端接口鉴权。否则别人猜到接口地址直接发起请求,就能越权拿到数据。
2.2 核心业务模块拆解
整个系统的功能围绕“报、查、管、统”四个字展开:
- 报:学生每日健康上报,包括体温、健康状况、是否在校、行程状态。这是数据入口,也是最核心的高频操作。
- 查:各级管理人员查询本组织范围内的人员健康数据。辅导员查班级,院系管理员查全系,校级管理员查全部。
- 管:对异常数据的管理,包括体温异常标记、疑似症状登记、隔离观察人员台账、出入校审批。
- 统:维度汇总统计,按学院、班级、时间三个维度做上报率、异常率、趋势分析。
这四个字对应到系统菜单,就是学生端的三五个功能和后台管理的七八个功能模块,不再堆砌更多花哨功能。我们当时犯过的错是把功能设计得很“全”,结果很多菜单长期闲置,还分散了开发精力。后来砍掉一批低频率功能,系统反而更好用了。
2.3 角色权限设计的关键思路
权限设计是这类系统的命根子。我们的方案是RBAC(基于角色的访问控制),但做了一层简化:不搞细粒度到按钮级别的权限,只控制到菜单和接口粗粒度。
具体来说,四类角色各有一套可见菜单和可用接口:
学生端:个人信息、每日上报、出入校申请、我的上报记录。 辅导员端:班级学生列表、上报情况总览、异常学生处理、请假/出入校审批。 院系管理员:院系内各班级数据汇总、异常人员台账、导出报表。 校级管理员:全校数据看板、各院系数据对比、基础数据管理(学院、专业、班级、用户)、系统参数配置。
后端在拦截器里校验Token携带的角色信息,再判断当前请求路径的权限标识。我们给每个接口定义权限码(比如report:student:submit表示学生提交上报),在配置类里做路径与权限码的映射,实现起来不复杂、维护也方便。
值得留意:角色与用户的关系建议做成关联表,而不是在用户表里写死一个角色字段。因为实际情况中会出现“学生兼任辅导员助理”这类跨角色需求,关联表更灵活。我们后来就是因为一开始写死了角色,改动时多花了不少时间。
3. 数据库设计与关键表结构
3.1 核心数据表清单
整个系统我梳理下来,核心表控制在12张左右就够了。当时团队里有同学一口气设计出30多张表,很多表之间关系冗余,反而给后续开发添乱。我们用到的核心表如下:
| 表名 | 用途 | 说明 |
|---|---|---|
| sys_user | 用户表 | 统一存放所有人员,包含工号/学号、姓名、密码、类型 |
| sys_role | 角色表 | 定义角色类型 |
| sys_user_role | 用户角色关联表 | 支持一人多角色 |
| bus_academy | 学院表 | 学院基础信息 |
| bus_major | 专业表 | 关联学院 |
| bus_class | 班级表 | 关联专业 |
| bus_student | 学生信息表 | 拓展的学生档案,关联sys_user |
| bus_report | 健康上报表 | 每日上报记录,核心数据表 |
| bus_temperature | 体温明细表 | 记录多时段体温值 |
| bus_leave | 出入校申请表 | 请假/出入校登记 |
| bus_abnormal | 异常台账表 | 异常人员跟踪记录 |
| bus_notice | 通知公告表 | 系统公告推送 |
sys_user和bus_student拆成两张表,一开始有同学不理解,觉得学生信息里直接放登录账号不就行了。但实际使用中,账号信息(密码、状态)和档案信息(班级、学号、宿舍)变更频率完全不同,拆开后互不影响,做账号启停用的时候也不用连带更新档案。
3.2 健康上报表的核心字段设计
健康上报表是整个系统的心脏,字段设计直接决定后续统计功能的开发成本。我们经过两个版本迭代,最终保留的核心字段如下:
id varchar(36) 主键(如有需要不会用数据库自增,避免分表扩容困难) student_id varchar(36) 学生档案ID user_id varchar(36) 用户ID(方便关联查询) report_date date 上报日期 temperature decimal(4,1) 当日最高体温 health_status tinyint 健康状况(1正常 2可疑症状 3确诊/密接等) is_on_campus tinyint 是否在校(1在校 0校外) is_vaccinated tinyint 接种状态 location varchar(200) 当前位置 remark varchar(500) 备注 create_time datetime 提交时间 check_status tinyint 审核状态(0待审核 1通过 2异常待处理) check_remark varchar(200) 审核备注这里有两个容易被踩坑的点:
一是report_date和create_time要分开。上报日期是业务时间,创建时间是系统时间。有同学图省事只保留create_time,结果遇到学生深夜提交凌晨的体温时,统计口径就乱了。
二是健康状态和审核状态要分开。学生可能填报体温正常但处于隔离期,此时系统应该置为“异常待处理”,但又不能简单覆盖学生的健康状态字段。独立审核状态位,让审核链路更清晰。
3.3 索引设计与事务处理心得
健康上报表用的最多的是两个查询:查某人某天上没上报,和按日期查全班/全院的上报记录。针对这两个高频查询,我们建了组合索引:
ALTER TABLE bus_report ADD INDEX idx_student_date (student_id, report_date); ALTER TABLE bus_report ADD INDEX idx_date_class (report_date, class_id);查某人某天是否已上报时,走第一个索引,命中的记录数最多一两行,速度极快。而每天统计上报率时,各班级的查询都走第二个索引,避免全表扫描。
事务处理集中在两个地方:一个是提交上报时同时插入主表和明细表,需要保证原子性;另一个是批量导入学生数据时需要一批全部成功或全部回滚。我们统一在Service层加了@Transactional注解,同时注意不要把大循环套在事务里——一次导入一万条数据,逐条Insert再加上事务,数据库压力会非常大。我们后来改成批量插入(每500条提交一次),整体耗时降了接近一个数量级。
注意:MyBatis的批量插入有两种方式:用foreach标签拼接SQL,或者使用ExecutorType.BATCH的批处理模式。前者SQL字符串会很长,受max_allowed_packet限制;后者是JDBC级别的批处理,性能更优。我们实际项目中推荐后者。
4. 后端SpringBoot实现要点
4.1 统一返回结果与异常处理
后端接口设计最重要的一件事,是把返回格式统一。我们定义了一个Result 类,所有接口返回值都包一层code、message、data:
public class Result<T> { private Integer code; // 200成功,其他为失败 private String message; // 提示信息 private T data; // 业务数据 }对应地,在Controller层不直接捕获业务异常,而是抛给全局异常处理器统一处理。我们用@RestControllerAdvice做了一层拦截,把校验异常、业务异常、系统异常分别映射到不同的业务状态码。这样做的好处是,前端拿到响应时只用判断一个code,不用为每个接口单独写错误处理逻辑。
实际开发中,我们遇到过最头疼的返回格式问题是在分页查询上——有同事把total和records拆开放在两个字段里,前端每次都要做特殊处理。后来我们把分页结果统一成PageResult 结构(包含total、pages、list三个字段),全项目只维护这一套,新来的开发也能很快上手。
4.2 登录鉴权与Token机制
登录这块我们没用Spring Security(配置学习成本偏高,对校内系统有点重),而是用拦截器+JWT的轻量方案。
用户登录成功后,后端签发一个Token,里面加密存储userId、roleCode、expireTime三个关键信息,返回给前端。前端把Token存在localStorage里,每次请求在请求头里带上。
后端拦截器做两件事:先校验Token有效性,再通过自定义注解@RequirePermission校验权限码。
@Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { String token = request.getHeader("Authorization"); // 1. 校验Token if (!jwtUtil.validateToken(token)) { throw new AuthException("登录已过期,请重新登录"); } // 2. 解析用户角色与权限 Claims claims = jwtUtil.parseToken(token); // 3. 校验当前接口所需权限码 if (handler instanceof HandlerMethod) { HandlerMethod hm = (HandlerMethod) handler; RequirePermission annotation = hm.getMethodAnnotation(RequirePermission.class); if (annotation != null) { String[] needCodes = annotation.value(); // 对比当前用户拥有权限码集合 if (!permissionService.hasPermissions(claims.get("userId"), needCodes)) { throw new AuthException("无权限访问"); } } } return true; }这套方案有几个好处:实现难度低,一个拦截器加一个工具类就够;跟业务解耦,不下沉到数据层;每个接口通过注解声明权限码,接口权限一目了然。
当时还遇到一个坑:Token有效期设太短,学生早上登录,中午刚提交上报就提示过期,体验很差。后来把有效期调到12小时,并做了“记住我”的延长逻辑,问题解决。这类系统的用户体验优先级其实很高,因为每天都要用,任何一点卡顿都会被放大。
4.3 健康上报接口的防重复设计
健康上报有个核心需求:每人每天只能报一次。很多初级开发者会直接在Service里加个判断:先查表,存在就不让提交。但这样在高并发下会出现“同时查不到、同时都插入”的竞态问题。
我们当时采用的方案是在数据库层面做约束,同时利用唯一索引来兜底:给bus_report表加一个(student_id, report_date)的唯一索引,然后在Service里显式捕获DuplicateKeyException,转成友好的提示返回给前端。
@Transactional(rollbackFor = Exception.class) public void submitReport(ReportSubmitDTO dto) { try { reportMapper.insertReport(dto); } catch (DuplicateKeyException e) { throw new BizException("今天已经提交过上报,请勿重复提交"); } }这个方案干净利落,前端拦截只是提体验,数据库约束才是最终防线。后来压测时并发一百个请求同时提交,最终也只有一条数据插进去,这在生产环境验证过是可靠的。
4.4 定时任务与数据统计
疫情期间每天要把各学院上报率汇总给校办,人工统计太痛苦。我们在后端配置了定时任务,每天凌晨一点生成前一天的统计快照,存到统计表里。同时提供实时统计接口,给管理端大屏用。
这里分享一个判断:统计功能建议“实时查询+定时快照”双轨并行。实时查询用于白天看板更新,数据最新;定时快照用于生成报表和领导汇报,数据固定不会变。如果只做实时查询,遇到今天数据量大、查询节点重合时,大屏接口和报表导出会互相争抢数据库资源。
5. 前端Vue实现要点
5.1 路由与菜单动态化
前端这边,学生端和后台管理共用一套Vue工程,通过路由懒加载和动态路由做模块隔离。登录完成后,前端根据当前用户角色请求后端接口,动态生成可以访问的菜单和路由。
动态路由的实现要特别注意刷新页面后路由丢失的问题。我们一开始没做持久化,用户一刷新页面就白屏。后来把路由生成的逻辑收敛到一个统一方法里,并在路由守卫中检测:如果当前路由不在已注册的动态路由列表中,就重新拉取用户信息并重新注册路由。
5.2 接口封装与状态管理
前端所有API请求都走统一的request模块,内部基于axios二次封装。封装时做三件事:请求拦截器里自动带Token、响应拦截器里统一处理业务错误码并弹出提示、遇到401状态码自动跳转登录页。
// 统一请求处理 service.interceptors.request.use(config => { config.headers['Authorization'] = getToken() return config }) service.interceptors.response.use(response => { const res = response.data if (res.code !== 200) { Message.error(res.message) if (res.code === 401) { userStore.resetAndRedirectLogin() } return Promise.reject(new Error(res.message)) } return res.data }, error => { // 网络异常统一处理 Message.error('网络异常,请稍后重试') return Promise.reject(error) })状态管理层我们用了当前主流的Pinia。全局只维护三个store:用户信息、上报状态、应用配置。不把每个页面数据都放store里,避免状态满天飞、调试困难的局面。表格数据尽量在页面组件内部维护,只有在多个页面之间共享的数据才提升到store。
5.3 报表可视化与导出功能
管理端的数据看板用ECharts做图表展示,覆盖上报率趋势、各学院异常统计、体温分布直方图几个核心场景。这里有一个实际经验:不要把图表初始化逻辑直接写在页面里,抽成一个ChartCard组件,传入配置项,统一管理图表的加载、销毁和自适应。
导出功能当时也踩了坑。后端直接用POI导出Excel,前端通过Blob方式下载文件。最开始遇到的问题是文件流没有附带响应头,导致前端无法从response header里拿到文件名。后来前后端约定文件名编码放在Content-Disposition里,前端做一层解析,问题解决。
6. 联调、部署与问题排查
6.1 前后端联调踩坑记录
联调阶段最常见的问题集中在跨域、参数格式、时间格式三块:
跨域我们采用后端配置跨域过滤器的方式解决,前端Nginx做反向代理时再配一层,双保险。参数格式方面,前端传后端的时间字段一开始是带T的ISO字符串,后端LocalDateTime默认解析不了。统一在全局做了Jackson配置,指定时间格式为yyyy-MM-dd HH:mm:ss,问题消除。
还有一次很典型的联调问题:前端页面显示列表数据正常,但导出Excel时始终报错。排查半天发现是接口返回的Excel文件流被axios默认当成JSON解析了。解决方式是在response拦截器里判断响应类型,如果是Blob直接返回。
6.2 部署上线要点
部署方案可以灵活,但一定要保证发布过程可回滚。我们最终的方案是:
- Java后端打jar包,用systemd服务管理,每次发布前备份旧包,发布新包后校验启动日志里没有报错再切换流量。
- Vue前端打包成dist目录,放到Nginx的站点目录下,同样是先备份再替换。
- 数据库变更单独准备SQL脚本,每次升级按版本号顺序执行,不直接手工改表。
提示:校内系统经常出现“功能改了但用户说没变”的情况,多半是浏览器缓存。前端发布时让Nginx对静态资源设置带hash的文件名,并关闭入口index.html的缓存,能大幅减少这类反馈。
6.3 常见错误与解决方案速查表
| 现象 | 可能原因 | 解决方案 |
|---|---|---|
| 学生提交上报提示重复 | 前端重复点击 | 提交按钮加loading禁用态;后端唯一索引兜底 |
| 页面列表加载慢 | 单表数据量大,缺少索引 | 检查高频查询条件,补充组合索引 |
| 批量导入学生数据超时 | 单条插入循环 | 改为JDBC批量插入模式 |
| 看板数字和报表对不上 | 实时数据和快照差异 | 明确两套数据的使用场景,不混用 |
| 某个接口偶尔报500 | 日志定位为空指针 | 检查多表关联查询中关联字段是否有null |
| 导出文件乱码 | 文件编码不一致 | 统一导出文件为UTF-8并带BOM头 |
整条链路做下来,我最大的体会是:这种系统真正花时间打磨的地方不在技术难点,而在于把每个角色每天要用到的操作做到极致顺手。上报快、查询快、导出快、权限不出错、数据不丢,这五件事做好了,系统就成功了八成。后续做类似管理系统时,这套SpringBoot+Vue+MyBatis+MySQL的组合方案可以低成本复用,只需在业务表和权限模型上做调整即可,开发周期能压缩到相当短的程度。