做这类系统之前我先说句实在话健康打卡和健康评测看起来就是个填表汇总的小功能但真正落地的时候涉及用户身份、每日一次的限制、评测规则、后台日报合并这些细节随便一个没想清楚就会返工。这篇文章是我自己实际做完一个基于SpringBootVue的健康打卡健康评测管理系统之后整理出来的完整复盘不含任何花架子从需求边界、数据库设计、后端业务链路到前端页面的关键代码都给你过一遍。面向的是正在做课程设计、毕业设计或者公司内部想搭一套轻量级健康信息采集系统的开发者照着这个思路走能少踩不少坑。1. 这类健康管理系统的真实痛点和需求边界1.1 为什么不能只用现成的在线表单很多人第一反应是健康打卡不就是做个问卷表单嘛用现成的在线表单工具发个链接让大家每天填一下后台再导出Excel搞定。我在接这个需求之前也是这么想的直到被现实教育了一顿。第一个问题就是每日一次的限制。在线表单工具很难约束同一个人不能重复提交虽然有的平台可以做限制但通常是按设备或者按登录账号粒度不够。而健康打卡这类业务核心规则就是每个用户在有效日期内只能提交一次管理员可以纠错补录这个逻辑自己写接口也就是几十行代码的事但靠通用表单工具你得绕很多弯。第二个痛点是评测逻辑。这个系统不只是打卡还有一个健康评测功能。用户在打卡的时候要顺手填几个维度的指标系统根据预设规则算出一个健康评分并且把评分分档正常、关注、异常。这种东西用表单工具根本算不了只能导出后拿Excel再做二次加工。第三个痛点是权限和审计。管理员要能看得到今天谁没打卡部门负责人要能看本部门情况普通用户只能看自己的记录。这种分级数据隔离通用表单工具给不了只能自己写权限模型。1.2 系统要管的人、物和流程我做的这个系统业务背景是某高校内部用于学生日常健康状态登记。需求方一开始给的描述很模糊就说做一个打卡系统学生每天报一下体温和健康状况辅导员能看到异常情况。听起来简单但真正细化之后需求其实是这样的用户分三类学生普通用户、辅导员部门管理员、系统管理员。学生每天只能提交一次打卡表单表单包含体温、健康码状态正常/异常、行程轨迹校内/校外、当前健康状况描述、是否接触过风险人员等字段。健康评测不再是简单的正常/不正常而是把体温数值、接触史、健康状况描述这几个字段映射成分数加权求和得到一个评测总分。管理员端要有日报看板展示今日打卡率、异常人数分类统计、近一周趋势。系统管理员可以管理用户、重置密码、补录打卡、导出报表。你看光是这几条就已经是一个标准的管理系统了根本不是一个表单链接能搞定的。我拿到这个需求之后第一件事不是写代码而是先画业务流程图和数据流转图把谁在什么时间通过什么入口做了什么操作数据到了哪里理清楚。这一步做完后面写代码顺畅很多。2. 技术选型SpringBoot Vue MyBatis MySQL 背后的取舍逻辑2.1 为什么后端是SpringBoot而不是其他方案标题写的是SpringBoot但我在实际选型的时候还是纠结过一下的这个系统这么轻用Python的Flask或者Django行不行甚至用Node.js的Express行不行当然行。但最后选了SpringBoot理由有几个。第一是生态成熟度。SpringBoot集成了几乎你需要的所有东西Spring Security做鉴权、Spring MVC做接口、Spring Validation做参数校验再加上MyBatis做持久化这一套在Java里属于标配。社区问题多遇到坑一搜就有答案这对做项目的人来说非常重要。第二是部署方便。这个系统要部署在学校的服务器上运维那边最熟悉的就是Java环境。SpringBoot内置Tomcat打成一个可执行的Jar包丢上去就能跑。不像Python项目还得配置虚拟环境、装依赖、处理WSGIJava项目虽然笨重一点但部署路径最省心。第三是人员的可维护性。需求方后期可能找学生助理来改功能Java SpringBoot是国内计算机课程里覆盖率最高的技术栈找个能看懂的人容易。用Flask的话虽然我自己写起来爽但接手的同学不见得会。当然SpringBoot也不是没缺点。项目一启动内存占用就几百兆对于一台低配服务器来说确实心疼。但考虑到系统的稳定性优先这个代价可以接受。2.2 为什么ORM选MyBatis而不是JPAORM层面我几乎没怎么纠结。JPASpring Data JPA写起来是省事实体类一定义增删改查全自动但问题在于它的复杂查询能力让我不放心。健康打卡系统的报表查询其实很有特点按日期分组统计打卡人数、统计每个评测档位的人数、统计某个用户一段时间内的体温变化曲线。这些SQL用JPA写就得写Query原生SQL或者Specification绕来绕去最终还是逃不开SQL本身。MyBatis的思路就直白得多你写SQL我帮你映射结果。我可以在Mapper XML里精确控制每一条SQL包括Linq哦不包括多表关联、GROUP BY、日期函数这类操作手写起来心里有底。评测系统的数据统计语句要方便调优MyBatis直接改XML就行不用重新编译。另一个实际的考量是动态SQL。打卡报表经常要根据日期范围、部门、评测结果等条件组合筛选用MyBatis的where、if标签写动态条件非常顺手JPA在这方面写起来就显得啰嗦。当然MyBatis也不是没有代价。每张表都要写Mapper接口和XML比JPA多出不少样板代码。所以我在项目里做了一个折中简单的单表CRUD用MyBatis-Plus的BaseMapper复杂的统计查询才手写SQL到XML里。这样既省代码又保留了SQL控制力。2.3 Vue 2还是Vue 3怎么选前端标题说“Vue”具体用Vue 2还是Vue 3我在工程里选的是Vue 3 Element Plus Vite。理由很简单这是新项目没有历史包袱没必要继续用Vue 2。Vue 3的组合式APIComposition API在业务逻辑复用的体验上比Vue 2的Options API好很多。比如打卡页面的表单校验逻辑、评测结果的展示逻辑我可以抽成自定义的composition函数在多个组件之间复用代码会干净很多。Element Plus是Vue 3对应的组件库后台管理界面的表格、表单、对话框、日期选择器都是一套现成的省去自己写样式的功夫。Vite做开发服务器启动速度比Webpack那会快太多了几乎秒开日常联调体验提升明显。还有个细节如果读者用的还是Vue 2也不用慌。这篇文章的核心逻辑是一样的只是组件写法不同你只要把setup语法改成data/methods就能迁移过去。2.4 一套能跑通的架构分层这个项目不算大但为了后面不越写越乱我一开始就定了四层结构前端Vue 3 Vite Element Plus Pinia Axios。后端SpringBoot Spring Security MyBatis。数据库MySQL 8.x。中间件无。就这么简单不需要Redis、不需要消息队列。后端包结构我按业务模块而不是按技术层来划分com.healthsystem ├── controller # 接口层 ├── service # 业务层 ├── mapper # 数据访问层 ├── entity # 实体类 ├── dto # 入参出参对象 ├── common # 统一返回、异常处理、工具类 └── config # 安全、跨域等配置按业务模块划分听起来很常规但很多新手会把权限相关的代码和打卡业务混在一起时间一长就成一堆意大利面条。我把用户、打卡、评测、报表拆成几个独立的业务包每个包内部再分controller/service/mapper互相之间通过service方法调用而不是直接访问对方的mapper。这样做的目的只有一个后面加功能的时候改一个模块不会炸掉另一个模块。3. 数据库设计健康评测系统的表结构和字段为什么这么定3.1 五张核心业务表这个系统的表结构不多但每张表的设计都是我根据业务反复调整过的。核心的是下面这五张sys_user # 用户表 sys_role # 角色表 user_role # 用户角色关联表 health_checkin # 健康打卡记录表 health_evaluation # 健康评测结果表严格来说还有一张sys_dept部门表用来表示学院/班级的层级关系但为了文章篇幅我先聚焦在五张核心表上。用户表的字段在设计上要特别注意一个问题用户登录账号用的是学号/工号而不是手机号。这在校园场景里很实际学生都有学号导入的时候也方便。密码字段存的是BCrypt加密后的哈希值不是明文这个没有商量余地。角色表我做了个简单的三档ROLE_STUDENT、ROLE_COUNSELOR、ROLE_ADMIN。没有把权限粒度细化到按钮级别因为业务不需要三档角色足够覆盖学生提交、辅导员查看本部门、管理员管全局的权限模型。3.2 健康评测分数的计算字段设计health_checkin表是整个系统的核心它的字段不光要存展示用的原始数据还要存计算评测分数所需的因子。我把相关字段摘出来给你看CREATE TABLE health_checkin ( id BIGINT PRIMARY KEY AUTO_INCREMENT, user_id BIGINT NOT NULL COMMENT 打卡用户ID, checkin_date DATE NOT NULL COMMENT 打卡日期, temperature DECIMAL(4,2) NOT NULL COMMENT 体温, health_code TINYINT NOT NULL COMMENT 健康状态1正常 2异常, contact_history TINYINT NOT NULL COMMENT 接触史0无 1有, location_type TINYINT NOT NULL COMMENT 行程1校内 2校外, symptom_desc VARCHAR(500) DEFAULT NULL COMMENT 症状描述, evaluation_score INT DEFAULT NULL COMMENT 评测分数, evaluation_level VARCHAR(10) DEFAULT NULL COMMENT 评测等级, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_user_date (user_id, checkin_date) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;注意这个唯一索引uk_user_date这是实现每人每天只能打卡一次的数据库层面的兜底方案——即使应用层代码出了问题两个并发请求同时插进来数据库也会拒绝第二个保证数据不会出现同一个人同一天两条记录。体温字段用DECIMAL(4,2)而不是FLOAT这是个细节。浮点数在比较大小的时候容易出精度问题而健康评测规则里有体温超过37.3度则触发关注这种判断用DECIMAL才能保证比较的绝对精确。3.3 打卡状态的幂等设计考虑数据库层的唯一索引已经解决了并发的问题但这还不够。用户可能会遇到这种情况打卡提交后页面卡住了他下意识地点了两次提交按钮前端已经拦截了但请求还是可能发出两个。这时候后端如果只做唯一索引约束第二个请求会直接抛异常用户看到500错误会很困惑。所以我在服务层加了一步先查后插的逻辑Override Transactional public CheckinResult doCheckin(CheckinRequest request) { // 先根据用户和日期查已有记录 HealthCheckin existing checkinMapper.selectByUserAndDate( request.getUserId(), request.getCheckinDate()); if (existing ! null) { // 已存在则直接返回当前记录不报错 return CheckinResult.alreadyExists(existing); } // 不存在才执行插入 ... }这个设计看起来很基础但实际体验差别很大如果用户重复提交得到的是您今日已打卡无需重复提交的友好提示而不是操作失败那系统的信任感会强很多。至于并发穿透的问题交给数据库唯一索引兜底两条机制配合不会出现脏数据。4. 后端核心实现打卡接口、评测逻辑和后台管理的代码链路4.1 登录鉴权JWT怎么接入这个系统用的是JWTJSON Web Token做无状态鉴权。我的做法比较常规用户登录成功后后端生成一个带有用户ID、用户名、角色信息的token返回给前端前端后续每个请求都在Authorization头带上这个token后端用一个拦截器解析token并放到当前线程上下文中。具体到SpringBoot工程我写了一个JwtInterceptor实现HandlerInterceptor在preHandle里做token校验。同时配合WebMvcConfigurer注册拦截器并排除登录接口和静态资源路径Configuration public class WebConfig implements WebMvcConfigurer { Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(jwtInterceptor) .addPathPatterns(/api/**) .excludePathPatterns(/api/auth/login, /api/auth/register); } }一个重要的经验如果用Spring Security它会先接管所有请求JWT拦截器根本不会执行。我最初就是把两者一起用结果登录接口返回403排查了半天。后来我把Spring Security去掉只保留Spring MVC的拦截器方案针对这个体量的内部系统完全够用还少了一堆Security配置的麻烦。如果读者非要上Spring Security要注意放行登录接口和安全过滤器链的配置这是最容易踩的坑。4.2 健康打卡接口一次提交不能被覆盖打卡接口的完整链路是前端提交表单数据 → controller接收并做参数校验 → service层查重并插入记录 → 根据评测规则计算分数 → 更新评测结果 → 返回提交后的完整记录。Controller层我做了一个细节统一返回体ResultT不管成功失败前端拿到的都是一个固定格式的JSON里面分别是code、message和data。这样前端处理异常就非常统一不用针对每个接口单独判断。参数校验我用了Validated注解配合NotBlank、DecimalMax这类约束注解但要注意一点——体温字段的校验不在注解里写死上限而是放到service层的评测规则里。因为体温超过37.3只是评测分档不同不代表格式非法。如果注解里限制只能填35-42度那用户填一个真实的38.5体温会被当成参数错误拒绝掉这就尴尬了。4.3 健康评测分数计算规则引擎还是硬编码评测规则是需求方最常改的地方。一开始他们说体温超过37.3就是异常其他都是正常很简单我直接在service里写个if就完事。后来他们说不行我这个评测要科学一点于是变成体温37.3以下得基础分60分37.3到38.0之间扣10分38.0以上扣20分有接触史直接扣30分健康码异常扣20分行程在校外扣5分。最后总分超过80为正常60到80为关注低于60为异常。这种规则用状态机或者规则引擎太重了我用的是一段独立的评分方法集中在EvaluationService里public EvaluationResult evaluate(HealthCheckin checkin) { int score 100; if (checkin.getTemperature().compareTo(new BigDecimal(37.3)) 0) { score - 10; } if (checkin.getTemperature().compareTo(new BigDecimal(38.0)) 0) { score - 10; } if (checkin.getContactHistory() 1) { score - 30; } if (checkin.getHealthCode() ! 1) { score - 20; } if (checkin.getLocationType() 2) { score - 5; } String level; if (score 80) { level 正常; } else if (score 60) { level 关注; } else { level 异常; } // 分数下限兜底 score Math.max(score, 0); return new EvaluationResult(score, level); }规则集中放在一个service方法里好处是需求方改规则的时候我只需要动这一个方法不需要去打卡逻辑里面翻if。当时需求方前前后后改了三次评分规则每次都是改完打分方法、重启、验证对比表整个过程没动过别的业务代码这就是集中管理的好处。4.4 后台报表查询按日期聚合的SQL后台管理最核心的查询是日报看板要展示三块内容今日打卡总数与应打卡总数、异常人员名单、近七天打卡趋势。第一块我直接查用户总数和今日打卡数用两个select count就能完成。第二块要关联用户表和打卡表筛选出评测等级为异常的记录。第三块是最复杂的——近七天的每日打卡数。MySQL实现这种按天分组统计很直接SELECT checkin_date AS date, COUNT(*) AS cnt FROM health_checkin WHERE checkin_date BETWEEN #{startDate} AND #{endDate} GROUP BY checkin_date ORDER BY checkin_date ASC;万一某天没有人打卡这个查询结果里就没有那天的数据前端图表会缺一个点。我在Service里做了补零处理先把查询结果转成MapLocalDate, Long然后遍历近七天的日期列表如果map里没有就补一个0。这个细节很值得提因为做趋势折线图的时候缺少日期点会导致图线断裂看起来就像系统出Bug了。5. 前端Vue实现从登录页到后台看板的关键代码5.1 路由和拦截器前端的路由设计我按角色划分而不是把页面全部平铺。router/index.js里配置了三个主要区域登录页、用户端主页、管理端页面。用户端页面包括打卡页和个人记录页管理端包括看板、人员管理、补录管理。路由守卫是前端的权限控制点router.beforeEach((to, from, next) { const token localStorage.getItem(token); if (!token to.path ! /login) { next(/login); } else if (to.meta.roles !to.meta.roles.includes(store.state.userRole)) { next(/403); } else { next(); } });这里有个做得不够好的地方我必须坦白角色信息我是存在store里的而store刷新后就没了所以一刷新页面store.state.userRole变成空路由守卫就会误判权限跳转到403。我后来在store初始化的时候加了从localStorage恢复角色信息的逻辑才把这个坑填上。如果读者做的时候遇到了刷新后跳403的问题可以先往这个方向查。5.2 打卡页面表单校验和状态反馈打卡页面的核心是一个表单字段有体温、健康状态、行程轨迹、症状描述。Element Plus的el-form提供了现成的校验机制我用几条简单规则控制必填项和数值范围const rules { temperature: [ { required: true, message: 请输入体温, trigger: blur }, { validator: (rule, value, callback) { const val parseFloat(value); if (isNaN(val) || val 35 || val 42) { callback(new Error(体温范围应在35-42度之间)); } else { callback(); } }, trigger: blur } ], healthCode: [{ required: true, message: 请选择健康状态, trigger: change }], locationType: [{ required: true, message: 请选择行程信息, trigger: change }] };这里的前端校验和后端注解校验是两层防护前端校验为了用户体验输入不合法直接给出提示不用等接口返回后端的校验才是真正防攻击的屏障。一定不要以为前端做了校验后端就可以偷懒抓包可以直接绕过前端发请求的。页面还有一个状态设计如果用户今天已经打过卡进入页面后表单应该变成只读展示状态并显示今日已完成打卡的标识不能直接编辑。这个状态我在mounted里请求了一次查询接口根据返回结果决定表单是disabled还是可编辑。5.3 后台管理看板ECharts日报展示后台看板我用了ECharts做图表页面加载时调用/api/admin/report/daily拿到数据渲染出打卡趋势折线图和异常人数柱状图。这里有个关键点ECharts图表的容器必须有一个明确的高度。我一开始在div上只设置了width: 100%没有设置高度结果图表死活不显示控制台也没有报错。后来给容器加了一个height: 300px图表立刻正常了。这听起来像个笑话但实际开发中确实很容易忽略。另外图表数据的结构要跟前端匹配好。我后端的返回值是这样的{ dates: [2025-03-01, 2025-03-02, 2025-03-03], counts: [120, 135, 128] }前端用两个数组分别作为X轴和Y轴数据简单直接。如果后端返回的是对象数组前端还得map两次才能提取出对应的数组多一道转换步骤没必要。6. 部署联调踩坑记录端口、跨域、时区和中文乱码6.1 跨域配置那点事开发环境前后端分离前端跑在localhost:5173Vite默认端口后端跑在localhost:8080跨域是跑不了的。我用了最稳妥的后端CORS配置方案没有在前端配代理Configuration public class CorsConfig implements WebMvcConfigurer { Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping(/api/**) .allowedOrigins(http://localhost:5173) .allowedMethods(GET, POST, PUT, DELETE) .allowedHeaders(*) .allowCredentials(true); } }注意allowCredentials(true)和allowedOrigins不能同时出现*通配符。我一开始配置的是allowedOrigins(*)结果前端带cookie的请求全部失败。因为这些请求用的token放在Authorization头里有人可能会说我不是通过cookie带的凭证是不是就不用管这个限制了但浏览器对于跨域请求的态度就是严格你只要开了allowCredentials(true)来源就必须明确指定。6.2 时区和日期边界问题这是我最想敲黑板强调的一个坑。打卡日期用的是LocalDate本意是跟数据库里的DATE类型对应避免带时区的麻烦。但SpringBoot默认的Jackson配置把LocalDate序列化的格式给定成了数组格式[2025, 3, 1]。前端拿到这个数组是懵的我一开始也懵了。后来在application.yml里加了两行配置解决spring: jackson: date-format: yyyy-MM-dd time-zone: GMT8配置完之后LocalDate输出为2025-03-01的字符串LocalDateTime输出为2025-03-01 12:30:45的字符串前后端直接对接不用任何转换。还有一个跟时区直接相关的坑查询今日打卡数据的时候如果后端的服务器时区是UTC而用户在东八区半夜0点到8点这段时间今日的定义会错位。解决方式很简单——Java里获取当前时间不要用new Date()然后去转直接用LocalDate.now()。因为它默认使用JVM所在时区只要JVM时区是Asia/Shanghai就不会错。6.3 数据库连接和字符集MySQL连接字符串里一定要显式带上时区和字符集参数spring: datasource: url: jdbc:mysql://localhost:3306/health_system?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/ShanghaiuseSSLfalse如果不加characterEncodingutf8即使数据库和表都设置了utf8mb4连接层也可能因为使用默认字符集而把中文变成???。如果serverTimezone不设置新版本的MySQL驱动会直接报错让你配置时区。表和字段的字符集我统一用utf8mb4而不是utf8。原因很简单utf8mb4是真正的四字节UTF-8能存下emoji字符和生僻字。用户的症状描述字段万一填了个特殊字符用utf8就会报Incorrect string value错误。6.4 常见启动报错与排查思路我把自己实际遇到的几类报错列成了一张排查清单写在这做个参考。Lombok版本跟JDK版本不兼容导致编译失败这个我遇到过不止一次。JDK 17以上的版本Lombok要用1.18.30以上的较新版本否则Data注解生成的getter/setter会直接编译报错。排查方法很简单看编译错误是否指向java.lang.NoSuchFieldError或者java.lang.NoClassDefFoundError这些字样如果是大概率是Lombok版本问题。MyBatis XML文件没被扫描到我在application.yml里配置了mybatis.mapper-locations: classpath:mapper/*.xml但是XML文件编译后如果不在classpath下启动时就会报Invalid bound statement (not found)。这个报错特别容易吓到新手其实原因往往很朴素——XML文件的路径和配置对不上。我的建议是用target/classes目录下的实际文件路径去对比配置不要凭感觉猜。前端请求404但接口地址看起来没问题这种情况八成是RequestMapping的路径拼错了或者后端接口的method类型跟前端请求方式不一致。排查方法很简单打开浏览器开发者工具看Network面板里准确的请求URL再去后端Controller里搜这个路径一行一行对。很多莫名其妙的问题最后都是一些低级的路径拼写错误。启动时报端口被占用Port 8080 was already in use这是Windows和Linux上都常见的问题。Windows上用netstat -ano | findstr 8080找到占用进程的PID然后taskkill /PID xxx /F杀掉就行。Linux上用lsof -i:8080。这些坑单个看起来都不值钱但在联调阶段碰到每一个都能卡掉半天时间。我现在做项目都会先把这些环境层面的问题提前踩一遍免得开发进行到一半才炸出来。最后说点实在话把整个项目从0到1做完我最深的体会是这类管理系统真正难的地方不在某个技术点而在把模糊的业务描述转换成明确的数据结构和技术方案。健康评测规则改了三次需求方一开始说简单判断后来又要加权打分最后又加了分档逻辑如果当初把规则散落在各个Service代码里改起来会非常痛苦而集中到EvaluationService之后每次修改都是十分钟的事。另一个体会是数据库设计的时候多花一点心思做唯一约束和字段设计后面能省十倍的事。uk_user_date这个索引加得太值了它让我在并发、重复提交、数据一致性这些方面几乎不用担心。如果你正在做类似的项目我的建议是先别急着撸代码花一个晚上把数据表结构设计好把评测规则和每人每日一次这种核心约束写清楚再开始动手。框架的东西网上教程一大把真正体现专业水平的就是你把这些边界条件和业务细节想得多周全。希望这篇复盘能帮你少走一些弯路。