Spring Boot疫情打卡健康评测系统实战:从设计到部署
最近在帮一位同学把选题编号11676的springboot疫情打卡健康评测系统从零到一完整落地从需求说明、数据库表结构、前后端接口一直写到服务器上的Docker部署。做完这个系统我最大的感受是凡是涉及“每日打卡 健康评测 统计报表”这条业务线的springboot项目表面上看着就是CRUD真正落地时每一步都在踩边界问题——同一个人同一天能不能重复打卡、评测规则怎么配置才能不改代码、凌晨定时统计时服务器时区差了8小时导致数据归错天。这篇博文就把这套系统的完整实现思路、关键代码和实际部署中的坑都写出来给准备做类似毕业设计或小型健康管理系统的朋友一个可以直接参考的版本。1. 这个打卡评测系统到底要解决什么问题1.1 从每天一张纸质表说起先说业务背景。高校、园区、小型企业这类场景在常态化公共卫生管理阶段每天都有一件绕不开的事收集每个人的健康状况。早期我见到的做法是辅导员在群里发在线表格每个人自己填体温、填是否咳嗽、是否接触过风险人群。看起来挺方便实际用起来全是问题。第一个问题是漏填率特别高。表格链接被聊天记录淹没总有几个人想不起来填。第二个问题是汇总难几百上千人的数据在表格里想看某个部门今天有多少异常得人工筛选加手工统计。第三个问题更致命——异常筛查全靠人眼谁填了咳嗽谁填了发热没有一个自动化的判断规则等发现问题经常已经是第二天了。这套疫情打卡健康评测系统要解决的就是把这些手工流程全部线上化用户每天打开页面完成一次打卡系统根据提交的体温和症状信息自动评测健康状态管理者在后台实时看到各部门的打卡率和异常名单每晚定时生成统计简报。1.2 功能清单拆解用户端和管理端各做什么确定了目标之后功能设计其实很自然。我从实际使用的角度把系统拆成用户端和管理端两块两张表列清楚用户端功能每日健康打卡提交体温、咳嗽/乏力/腹泻等症状、风险接触史、当前所在地健康评测结果提交后即时返回“正常/关注/异常”三个等级并给出简要提示历史记录查询查看自己过去14天或30天的打卡记录和健康状态变化个人信息维护修改手机号、查看所属部门、修改登录密码管理端功能人员管理导入用户、分配部门、启用/禁用账号打卡监控按部门实时查看今日已完成打卡和未打卡人员名单异常预警异常健康记录自动进入待办列表支持标记处理和填写处理说明统计报表按日/周/月统计打卡率、异常人数趋势、风险等级分布评测规则配置在页面上配置不同症状和体温的组合条件无需改代码这个功能划分基本覆盖了一个小体量健康管理系统的全部刚需。功能再多就容易变成摆设比如接GPS定位、人脸识别这些看起来高端实际使用频率和运维成本不成正比我都不推荐在初版里做。1.3 三类角色和权限闭环系统的使用角色我分了三类普通用户、部门管理员、系统管理员。对应到真实的组织结构普通用户是学生或员工部门管理员是辅导员或部门负责人系统管理员是信息中心负责系统运维的人。权限用经典的RBAC模型用户表存角色字段接口层面用Spring Security做鉴权前端根据角色控制菜单显隐。普通用户只能看到自己的打卡页面和记录部门管理员能看到本部门的数据但改不了部门外的内容系统管理员负责规则配置、人员导入、全局报表。这层设计在课程设计里可能感觉有点多余但实际部署到真实场景就会发现很有必要——没有权限隔离辅导员能看到全校数据隐私和安全都控制不住。权限这层哪怕做得简单也一定要有。2. 技术选型为什么是Spring Boot MyBatis Vue这一套2.1 Spring Boot自动装配到底帮我们做了什么主框架选springboot这是当前Java后端做中小型管理系统的事实标准没有太多可纠结的。它的自动装配机制把大量繁琐配置变成了约定俗成引入spring-boot-starter-web依赖后内嵌的Tomcat、DispatcherServlet、Jackson序列化这些组件会按默认配置自动生效开发者不需要去写一堆XML配置。我记得早期用Spring MVC搭项目光配置文件就要写web.xml、applicationContext.xml、spring-mvc.xml还要手动把项目打成war包丢到外部Tomcat里。换成Spring Boot之后一个带有SpringBootApplication注解的主类加上application.yml就能把项目跑起来。自动装配的原理说穿了也很简单SpringFactories机制会加载META-INF/spring.factories或AutoConfiguration.imports文件里声明的配置类再通过ConditionalOnClass、ConditionalOnMissingBean这类条件注解判断是否生效。理解到这个层面排查一些“为什么配置没生效”的问题就够用了。2.2 数据层为什么用MyBatis而不是JPA数据访问层我选了MyBatis。现在很多教程喜欢推MyBatis-Plus确实写单表CRUD非常快自带分页插件和代码生成器但我在这个项目里仍然用原生MyBatis原因有两个。一个是报表SQL的可控性。统计打卡率、按部门分组汇总、异常趋势这类查询通常需要join用户表、部门表、打卡记录表再配合日期函数做聚合这类SQL用MyBatis的XML文件写SQL语句是显式可见的哪里错了可以复制到数据库客户端直接调试。换成JPA或MyBatis-Plus的QueryWrapper虽然也能拼出复杂查询但生成的SQL经常和预期不一致排查成本更高。另一个是防止误更新。MyBatis的update语句必须手写where条件写错了数据库直接报错或更新0行风险是可控的。而JPA的派生方法或者ORM框架的自动更新新手经常出现没加条件就把整表数据改了的情况。这类事故我见过不止一次在健康数据这种敏感业务上绝对不能发生。2.3 前端Vue Element UI Axios的组合逻辑前端用的是Vue 2 Element UI Axios这三件套在中小型后台管理系统里依然是最稳的搭配。Vue 2的生态成熟Element UI的表单、表格、日期选择器直接覆盖了管理后台90%的界面需求。Axios负责调后端接口统一封装请求拦截器注入JWT token响应拦截器统一处理错误码。开发阶段前后端完全分离前端npm run dev跑在8080端口后端springboot跑在9090端口通过proxyTable把/api开头的请求转发到后端。上线部署的时候我直接把前端构建后的dist目录拷贝到springboot项目的src/main/resources/static下重新打包成一个jar文件。这样服务器上只需要跑一个Java进程不需要额外部署Nginx对小项目来说省了很多事。2.4 JDK和Spring Boot版本搭配的坑要提前避开版本选择是个容易被忽略但坑很多的地方。我在这个项目里用的是JDK 8 Spring Boot 2.7.18这是目前兼容性最好、网上资料最丰富、部署最省心的组合。如果用Spring Boot 3.x就必须配JDK 17以上而且原来的javax.servlet包都迁移成了jakarta.servlet很多老教程里的代码直接就报红。你想用Spring Boot 3.x配JDK 8启动就会直接失败根本跑不起来。另外Spring Boot 2.7到3.x之间Spring Security的配置写法变化也非常大原来继承WebSecurityConfigurerAdapter的写法在3.x里已经废弃了全部要改成基于SecurityFilterChain的Bean配置。如果你只是为了做课程设计或内部小系统没必要追新版本。稳定压倒一切先把功能跑通再考虑版本升级。3. 数据库设计五张核心表搞定主体业务3.1 用户表和部门表的细节设计数据库这块我最终确定了五张核心表用户表、部门表、打卡记录表、评测规则表、异常预警表。这几张表把前面拆解的功能全部覆盖掉了没有过度设计。用户表是系统的根基字段如下id主键自增username登录账号唯一索引passwordBCrypt加密后的密码绝不允许明文存储real_name真实姓名dept_id所属部门关联部门表role角色1普通用户 2部门管理员 3系统管理员phone手机号status账号状态1正常 0禁用部门表更简单id、dept_name、parent_id三个字段就够了稍微支持一下部门层级。部门表的意义不只是为了显示归属更是后续权限过滤的关键维度——部门管理员查数据时所有SQL都要带dept_id作为过滤条件。3.2 打卡记录表靠唯一索引防止同一天重复打卡打卡记录表是整个系统的核心业务表每天的数据都写在这里。字段设计id主键user_id用户IDclock_date打卡日期格式yyyy-MM-ddclock_time打卡时刻格式HH:mm:sstemperature体温保留一位小数symptoms症状列表JSON数组存储比如[cough,fatigue]contact_history14天内是否有风险接触史0否 1是health_level评测结果normal/attention/abnormalcreate_time记录创建时间最关键的设计是加了一条复合唯一索引UNIQUE KEY uk_user_date (user_id, clock_date)。这行索引是整个防重复打卡机制的数据库兜底。就算应用层代码有并发漏洞数据库层面也不会允许同一个用户在同一天插入两条记录。3.3 健康评测规则表别把规则写死在代码里很多同学做健康评测会直接在Java代码里写if (temperature 37.3) return abnormal。我一开始也是这么干的但后来发现一个现实问题公共卫生管理政策是会调整的。今天说咳嗽加乏力算异常明天可能加了嗅觉丧失也算异常如果规则写在代码里每次调整都要重新打包发布既慢又容易出错。所以我设计了评测规则表把评测条件作为配置项存进数据库id主键rule_name规则名称比如“体温过高异常”condition_json条件表达式JSON格式health_level命中后返回的健康等级priority优先级数字越小越先执行enabled是否启用实际评测时系统按priority升序取出所有启用规则逐条将用户提交的数据与condition_json比对若命中就返回该规则对应的健康等级。这个设计让管理员可以在后台页面直接新增规则不需要改动一行代码。3.4 异常预警表给管理端待办清单提供数据源异常预警表存的是被评测为“关注”或“异常”的记录这是管理端待办清单的数据来源。字段id主键user_id用户IDclock_date打卡日期warn_level预警等级content预警详情比如“体温38.1℃”“咳嗽伴乏力”handled处理状态0未处理 1已处理handle_time处理时间handler处理人ID每天定时任务跑完后会把当天所有非正常记录批量写入这张表。部门管理员登录后台看到的就是一条条待办点开就能看到用户填写的完整健康信息。这个设计的好处是异常数据不会被淹没在大量正常记录里管理人员打开系统第一眼就知道今天要处理哪些人。4. 核心功能实现细节从打卡接口到评测引擎4.1 打卡接口幂等性不能只靠前端按钮打卡接口是并发压力最集中的地方早上八点大家都习惯性打开手机打卡几十上百个请求几乎同时进来。我见过太多项目只在前端做了“提交后禁用按钮”这在正常使用没问题但网络卡顿时用户连续点击或重复提交后端还是可能插入多条记录。后端代码分了三层保障第一层Service层在插入前先查一次今天的记录是否存在存在就直接返回已有结果不报错也不重复写库。第二层事务加唯一索引兜底万一两个请求同时通过查询校验数据库插入时第二条会触发DuplicateKeyException捕获后同样返回已有记录不会让用户看到500错误。第三层接口层面做简单限流同一个用户对打卡接口的访问频率限制为每5秒一次用Redis的setnx实现超出直接提示“操作过于频繁”。打卡接口的核心代码结构大概是这样PostMapping(/clock) public Result clockIn(RequestBody ClockInRequest request) { Long userId SecurityUtils.getUserId(); String today LocalDate.now().format(DateTimeFormatter.ISO_DATE); // 1. 查重 ClockRecord exist recordMapper.selectByUserAndDate(userId, today); if (exist ! null) { return Result.success(exist); } // 2. 评测健康等级 String level healthEvalService.evaluate(request); // 3. 插入记录失败时捕获唯一索引冲突 try { ClockRecord record buildRecord(userId, request, level); recordMapper.insert(record); if (!normal.equals(level)) { warningService.createWarning(record); } return Result.success(record); } catch (DuplicateKeyException e) { ClockRecord again recordMapper.selectByUserAndDate(userId, today); return Result.success(again); } }这个方案的巧妙之处在于不管并发来多少个请求最后数据库里只会有一条记录而且用户永远拿到的都是成功响应。4.2 健康评测打分引擎规则可配置的思路评测引擎这块我用的规则存的是JSON格式的条件表达式。拿体温过高规则举例condition_json字段存的是{ logic: and, conditions: [ { field: temperature, operator: gt, value: 37.3 } ] }再比如“咳嗽乏力”规则{ logic: and, conditions: [ { field: symptom, operator: contains, value: cough }, { field: symptom, operator: contains, value: fatigue } ] }评测引擎拿到用户提交的数据后会把每条规则的JSON解析成Condition对象列表再逐个执行字段比对。字段值支持数字比较gt、lt、ge、le和包含判断contains、notContains基本覆盖了健康评测的所有业务场景。这里有一个关键设计是优先级。比如“体温高于37.3”和“咳嗽但无发热”可能同时命中规则解析顺序不能乱。我的做法是给每条规则配priority字段数字越小优先级越高评测时按优先级升序逐条执行命中即返回不再继续往下判断。这样管理员在配置时可以精确控制规则之间的覆盖关系。4.3 定时任务每天凌晨自动汇总生成健康简报系统中有一个每天都要跑的定时任务作用是统计前一天的打卡率和异常汇总生成一份简报数据存入统计表供管理端首页展示。实现方式用的是Spring Boot自带的Scheduled注解按cron表达式每天凌晨两点执行。Component public class DailySummaryJob { Scheduled(cron 0 0 2 * * ?) public void generateDailyReport() { String targetDate LocalDate.now().minusDays(1) .format(DateTimeFormatter.ISO_DATE); // 统计各部门打卡率 ListDeptSummary summaryList recordMapper.countClockRate(targetDate); // 统计异常等级分布 ListLevelCount levelCounts recordMapper.countByLevel(targetDate); // 批量写入统计表 summaryMapper.saveDaily(targetDate, summaryList, levelCounts); } }这里最容易踩的坑是服务器时区。默认情况下很多Linux服务器的系统时区是UTC比北京时间慢8小时。如果你直接用new Date()或者LocalDate.now()取日期在凌晨两点执行的定时任务取到的“昨天”实际上可能是错误的。我处理的方式是在JDK启动参数里强制指定时区或者更稳妥一点在application.yml里配置spring.jackson.time-zone: GMT8同时所有日期相关的查询都显式传入日期参数不依赖服务器默认时区。4.4 管理端报表手写SQL聚合和Excel导出管理端报表是整个系统最有“技术含量”的部分。以打卡率统计为例需求是按部门、按天展示比如“信息学院3月10日应到500人实到480人打卡率96%”。这个SQL要关联三张表SELECT d.id AS dept_id, d.dept_name, COUNT(DISTINCT u.id) AS total_user, COUNT(DISTINCT CASE WHEN cr.id IS NOT NULL THEN u.id END) AS clocked_user, ROUND( COUNT(DISTINCT CASE WHEN cr.id IS NOT NULL THEN u.id END) / COUNT(DISTINCT u.id) * 100, 1 ) AS clock_rate FROM dept d LEFT JOIN user u ON u.dept_id d.id AND u.status 1 LEFT JOIN clock_record cr ON cr.user_id u.id AND cr.clock_date #{targetDate} WHERE d.id #{deptId} GROUP BY d.id, d.dept_name核心逻辑就是LEFT JOIN加COUNT加CASE WHEN的组合COUNT DISTINCT保证一个用户只计入一次。这类SQL用MyBatis的XML写出来比用查询构造器清晰得多这也是我坚持用MyBatis的重要原因。报表导出我用的是EasyExcel一个阿里开源的工具。传入一个List对象列表和对应的Excel模型类就能生成xlsx文件。导出接口用HttpServletResponse输出流前端拿到的是文件流直接触发浏览器下载。注意导出数据量大的时候不要用同步接口最好后台异步生成再把下载链接推给前端不过这个系统最多几千人同步就完全够用。5. 从本地到服务器构建、部署与踩坑实录5.1 Maven多环境构建一套代码两套配置项目结构我用的是标准的单模块Maven工程没有拆多模块因为这个体量的系统拆模块反而增加复杂度。pom.xml里主要维护几个依赖spring-boot-starter-web、spring-boot-starter-security、mybatis-spring-boot-starter、mysql-connector-java、lombok、easyexcel。多环境构建靠的是Spring Boot的profile机制。我在src/main/resources下放了三个文件application.yml公共配置包含应用名称、端口、JWT密钥等application-dev.yml开发环境配置数据库指向本地MySQLapplication-prod.yml生产环境配置数据库指向服务器MySQL构建命令区分环境打包测试环境用mvn clean package -Dmaven.test.skiptrue激活dev正式部署激活prod。这里有个小建议生产环境的数据库密码不要直接明文写在application-prod.yml里用环境变量占位符替代比如${DB_PASSWORD}运行Docker容器时再通过环境变量注入。这样即使代码仓库被传到公开平台数据库密码也不会泄露。5.2 几个关键的配置文件核心的application.yml里我单独注意这几项配置server: port: 9090 spring: datasource: url: jdbc:mysql://${DB_HOST:localhost}:3306/health_clock?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/ShanghaiuseSSLfalse username: ${DB_USER:root} password: ${DB_PASSWORD:root} jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: GMT8 mybatis: mapper-locations: classpath:mapper/*.xml configuration: map-underscore-to-camel-case: true logging: level: com.example.healthclock: info file: name: /data/applogs/health-clock.log注意数据库连接串里的serverTimezone参数如果不加这个JDBC驱动和MySQL之间会出现时区报错典型表现是连接时抛异常或者日期时间差8小时。map-underscore-to-camel-case是MyBatis的经典配置开启后数据库的dept_id字段能自动映射到实体的deptId属性不用写一堆resultMap。日志文件建议放在系统盘的独立日志目录别和项目jar包放一起方便后续查看和清理。5.3 宝塔面板用Docker部署过程与坑服务器我用的是宝塔面板因为对不熟悉命令行的同学友好但部署方式不走宝塔的Java项目管理器而是用Docker。好处是环境隔离、卸载干净、升级方便。Dockerfile这样写FROM openjdk:8-jre-alpine LABEL maintaineryourname ENV TZAsia/Shanghai RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime echo $TZ /etc/timezone WORKDIR /app COPY target/health-clock-1.0.0.jar /app/health-clock.jar EXPOSE 9090 ENTRYPOINT [java, -Xms256m, -Xmx512m, -jar, health-clock.jar, --spring.profiles.activeprod]构建和启动命令也简单docker build -t health-clock:1.0.0 . docker run -d --name health-clock -p 9090:9090 \ -e DB_HOST172.17.0.1 -e DB_PASSWORDyourpass \ -v /data/applogs:/data/applogs health-clock:1.0.0这里有个细节从Docker容器连接宿主机MySQLhost不能填localhost或127.0.0.1要填172.17.0.1这是Docker默认网桥的宿主机地址。我第一次部署时在这个坑上花了半小时容器日志一直报Communications link failure后来才反应过来。CPU和内存配置我给了256到512MB的堆内存范围对一个小型管理系统完全够用。如果并发不大不需要额外加Redis和Nginx一个jar包跑到底反而简单。5.4 部署后的常见问题排查套路部署完系统能启动但功能异常我总结了一套排查顺序先看进程是否活着docker ps确认容器状态再看端口是否监听curl http://localhost:9090/api/health检查接口通不通然后查日志docker logs health-clock --tail 200看最后200行报错。90%的问题都能在这三步里找到线索。另一个高频坑是MySQL连接数不够。如果系统在高峰期大量请求同时到达mysql默认的连接池大小是100一旦跑满后面的请求全部排队等待表现为接口响应越来越慢。解决办法是把HikariCP的最大连接数调小一点或者把MySQL的max_connections调大。我一般配置spring: datasource: hikari: maximum-pool-size: 30 minimum-idle: 530个连接对这个小项目足够了还能避免把数据库连接池打满。6. 真实使用中发现的边界问题与改进6.1 集中打卡带来的并发压力怎么扛前面提到防重复打卡主要是幂等校验但真正到了早上七八点的高峰期还有第二个问题——接口响应变慢。一个用户点提交后端要查库、评测、再插库正常耗时30毫秒左右但几百人同时提交数据库的压力会成倍放大。我的优化思路分成两步。第一步是加一层Redis缓存把当天已打卡的用户ID列表缓存一份接口先查缓存命中就直接返回避免每次都查数据库。第二步是给打卡接口做简单限流保护同一用户5秒内只能提交一次防止用户端重试风暴把服务打挂。如果是几千人的规模可以再把打卡请求放进消息队列异步写入数据库但引入MQ组件会让系统复杂度明显上升对这个量级没有必要。先用缓存和限流顶住高峰期是最务实的方案。6.2 打卡日期和时间的判断时区bug差点造成误统计这个坑必须单独拿出来讲。系统联调阶段有一天的报表打卡率只有30%查了半天数据发现大量记录被归到了“昨天”。原因是测试服务器时区是UTC用户在北京时间早上打卡服务器记录的create_time还是前一天晚上。排查过程是这样的生产环境用的是Docker容器基础镜像没有设置时区导致容器内部是UTC。后来我在Dockerfile里强制设置了ENV TZAsia/Shanghai并创建时区软链问题才解决。所有涉及日期的查询我都建议显式用字符串格式比如2025-01-15做条件而不是依赖数据库的NOW()函数这样无论服务器时区怎么变日期判断都稳定。6.3 评测规则的边界体温分界和症状组合的优先级评测规则配置里有一个很典型的边界问题体温37.2℃算不算正常不同管理要求下答案不一样这也是我把规则做成可配置的初衷。但规则多了之后又出现新的问题——多条规则同时命中时返回哪个等级我举一个实际例子。规则A是“体温高于37.3且低于38.0返回关注”规则B是“体温高于38.0返回异常”。如果用户体温38.2两条规则都命中如果评测引擎按随机顺序执行可能有时返回关注有时返回异常数据就乱了。解决方式前面提到了给每条规则加priority优先级高优先级先执行命中后直接短路返回。这个设计虽然简单但保证了评测结果唯一且稳定。也正是这种边界问题让我意识到把规则抽象成配置是多么重要——政策变化的时候管理员只需要在后台调整规则的优先级或启用状态完全不需要重启服务。最后再分享一个小技巧。这种带评测逻辑的系统测试阶段不要只测正常数据一定要把体温37.3、37.4、38.0这些边界值都试一遍把症状组合的每种排列都构造一遍。我实测时发现条件表达式的比较运算如果用浮点数有些情况下会出现精度误差比如37.3在计算机里存的是37.300000000000004大于判断就可能误判。所以我最后统一把体温字段用BigDecimal而不是double接收才彻底解决了这个问题。类似这种边界坑光看代码是看不出来的必须靠真实数据去撞。