
今年上半年我接了一个比较有代表性的落地项目一套基于 Java Web 的疫情防控管理系统技术栈锁定 SpringBoot2 Vue3 MyBatis-Plus MySQL8.0最终交付物包含完整源码和整套开发文档。之所以想把这套系统拿出来聊聊是因为它非常典型——业务上属于信息登记和流程管理技术上则覆盖了从数据库建模、权限设计、前端表单联动到统计报表的完整链路适合做毕设、课程设计也适合想通过一个小而全的项目理解前后端分离开发的开发者。这篇文章我会把每个技术选型为什么这么定、每一层实现时踩过的坑、源码里哪些设计值得直接抄作业都整理出来。先说背景。这类系统的本质不是把技术做得多炫而是替一线工作人员把重复劳动自动化。疫情防控期间无论是学校、社区还是企业每天都要收集人员的基础健康信息比如体温、行程、核酸状态、异常症状等。最初很多地方靠微信群接龙加手工Excel统计数据散落、漏报错报多而且一旦要回溯某人某段时间的健康记录几乎无从查起。我这套系统要解决的问题就是三个稳定采集健康数据、快速发现异常记录、随时查得到历史数据和统计结果。从业务角度看系统需要的核心功能并不复杂——人员注册、每日健康打卡、异常上报和处理、管理员后台的数据列表与统计导出。但麻雀虽小五脏俱全要实现得干净前后端的每一层都需要仔细设计。下面我就按实际开发顺序把整个项目拆开讲。1. 为什么需要一套管理系统级别的Web服务核心需求与边界划分这类系统看起来只是一个表单加一个列表真正动手做的时候才会发现需求细节远比想象中多。1.1 我遇到的真实痛点项目最初的原型其实就是一张Excel表格。社区网格员每天把居民的健康信息填进去再由专人汇总。我梳理需求时整理了三个核心痛点数据分散且格式不统一。有人填正常、有人填无异常统计时根本没法做条件筛选。系统只要用下拉框和表单校验就能保证数据整洁。异常信息无法跟踪闭环。某人昨天体温偏高今天状态如何有没有人跟进核实Excel里看不出这条链路。所以系统里必须有异常记录表每次上报异常时自动生成一条待处理记录跟进人员可以更新处置状态。查询和统计非常吃力。历史记录按月导出、按楼栋或部门筛选、统计某段时间内的异常发生率Excel做起来极其痛苦而SQL加上MyBatis-Plus的QueryWrapper几行代码就能搞定。1.2 系统边界哪些功能必须做哪些功能坚决不碰我画功能清单的时候给自己定了一条原则不追求大而全只保证数据采集、查询、统计这条主链路的闭环。最终功能划分为四块人员管理账号维护、基础信息姓名、联系方式、所属组织、住址/门牌号等。这里的组织是一个通用树状结构可以适配学校、企业、社区等不同场景而不是写死楼栋或班级。健康上报每人每天一条健康记录包含体温、是否咳嗽、是否接触异常人员、行程状态比如当前所在地是否异常、当日的健康码状态等字段。前端要做重复提交拦截后端也要做唯一性约束。异常管理当健康记录命中异常规则比如体温超过37.3、行程状态异常自动进入异常列表管理员可以登记核实结果、处理状态。统计展示按日期范围、组织维度统计上报率、异常人数、异常类型分布用图表直观呈现。1.3 需求高频变化是这类项目最大的工程风险这里必须多说一句。我经历过很多类似项目最大的风险不是编码而是需求变化频繁。比如今天要求体温超过37.3算异常明天改成37.5今天按楼栋统计明天要按班级统计。所以设计时我给自己留了三个后路健康记录字段设计为核心字段 扩展JSON字段新增指标不用改表结构。异常规则单独建一张配置表阈值、判定逻辑可以动态调整而不是硬编码在代码里。组织维度采用树形结构无论后面按楼栋、班级、部门分组查询语句都能复用同一套逻辑。这三条决策在后来的迭代中帮我省了大量时间。很多同学做毕业设计时容易一上来就写代码结果中期改需求改到崩溃。先花两天把核心流程和数据流想清楚比什么都重要。2. 技术选型SpringBoot2 Vue3 MyBatis-Plus MySQL8.0这套组合是怎么定下来的技术栈的确定不是拍脑袋我每一项都对比过替代方案考虑的核心是生态成熟度、团队熟悉度、以及学习成本的可控性。2.1 SpringBoot2.7而不是SpringBoot3项目开始时SpringBoot3已经发布了但主流稳定生态依然集中在2.x尤其是写毕设和课程设计的同学很多学校的教学环境还是JDK8。SpringBoot2.7可以完美运行在JDK8上而SpringBoot3强制要求JDK17一旦选错开发机、服务器、虚拟机的JDK版本全都要跟着升纯属把复杂度引入不该引入的地方。我最终的依赖配置是SpringBoot 2.7.x MyBatis-Plus 3.5.x MySQL驱动8.0.x。这套组合在我实际开发中非常稳定网上参考资料也多遇到问题基本一搜就有答案。选型项我采用替代方案选择理由后端框架SpringBoot 2.7.xSpringBoot 3.xJDK8兼容、生态成熟、资料丰富ORMMyBatis-Plus 3.5.xJPA / 原生MyBatis单表CRUD零SQL复杂SQL又能手写前端框架Vue3 ViteVue2 WebpackComposition API适合中后台表单逻辑复用数据库MySQL 8.0MySQL 5.7窗口函数、JSON类型、utf8mb4更省心2.2 为什么ORM选择MyBatis-Plus而不是JPA我见过不少项目用JPA但在这类管理系统中MyBatis-Plus的优势非常明显。它的最大价值不是省了写SQL而是把单表CRUD抽成公共能力同时保留手写复杂SQL的灵活性。播个实际场景——健康打卡列表需要按日期、组织、异常状态多条件动态查询。用MyBatis-Plus的LambdaQueryWrapper大概是这样的LambdaQueryWrapperHealthRecord wrapper Wrappers.lambdaQuery(); wrapper.eq(StringUtils.hasText(date), HealthRecord::getReportDate, date) .eq(orgId ! null, HealthRecord::getOrgId, orgId) .eq(abnormal ! null, HealthRecord::getAbnormal, abnormal) .orderByDesc(HealthRecord::getReportDate);所有条件都是动态拼接没有空指针问题也不需要手拼SQL字符串代码可读性极高。而复杂报表查询比如按组织统计异常人数我直接在Mapper里写XML用MySQL8.0的窗口函数和连表查询搞定完全不受ORM限制。2.3 Vue3 Element Plus让表单密集场景的开发效率上来了管理后台最大的特点是表单多、表格多、交互状态多。Vue3的Composition API让我可以把某个业务模块的请求逻辑、表单校验、列表状态收敛到一个setup函数里而不是像Vue2那样把数据散在data、methods、watch各个角落。比如健康打卡表单我需要同时处理个人信息回显、今日是否已打卡、体温数值校验、多个选项的联动显示用Composition API组合自定义hook可以做得非常清爽// useHealthForm.js export function useHealthForm(personInfo) { const formRef ref(null) const form reactive({ temperature: 36.5, cough: 0, hasAbnormalContact: 0, tripStatus: normal, healthCode: green }) const validators { temperature: [ { required: true, message: 请输入体温, trigger: blur }, { validator: (rule, value, callback) { if (value 35 || value 42) { callback(new Error(体温超出正常范围)) } else { callback() } }, trigger: blur } ] } const submitDailyReport async () { const valid await formRef.value.validate().catch(() false) if (!valid) return // 调用后端接口 } return { formRef, form, validators, submitDailyReport } }这个hook在组件里一引用打卡页、补录页、移动端H5都能复用代码量直接砍一半。2.4 MySQL8.0带来的统计能力MySQL5.7也能跑这项目但8.0有几个特性确实好用。窗口函数让按组织排名计算环比变化这类统计SQL变得很直观JSON字段用来保存健康记录的扩展指标避免频繁ALTER TABLEutf8mb4字符集则保证了用户填写的生僻字和特殊符号不会乱码。实际项目里我用得最多的是按天统计异常人数的窗口函数SELECT report_date, abnormal_count, SUM(abnormal_count) OVER (ORDER BY report_date ROWS BETWEEN 6 PRECEDING AND CURRENT ROW) AS seven_day_total FROM daily_abnormal_stat;这段SQL在MySQL5.7里需要写复杂的用户变量在8.0里就是一行窗口函数的事。3. 数据库模型设计五个核心表如何撑起健康管理闭环数据库设计是整个项目的地基。我见过不少同学上来就建二三十张表结果一大半是冗余或错误设计。我的原则是能用五张表解决的事情不去凑第十张。3.1 核心表结构与职责表名核心职责关键字段sys_user登录账号体系id, username, password, role, person_idt_person人员基础信息id, name, id_card, phone, org_id, address, statust_org组织树学校/社区/企业通用id, parent_id, name, typet_health_record每日健康打卡记录id, person_id, report_date, temperature, cough, trip_status, health_code, abnormal, ext_jsont_abnormal_record异常处置闭环id, health_record_id, abnormal_type, description, handle_status, handler, handle_time人员表与账号表分离的原因很实际一个人员可能有多重角色比如既是学生又是志愿者账号系统不应该绑定人员的唯一性。身份证号虽然设计上可以唯一但实际业务中人员信息要先建立档案账号后创建两表分离更灵活。健康记录表是数据量最大的表我特意加了report_date与person_id的联合唯一索引从数据库层面防止重复提交。ext_json字段用来存动态扩展指标比如某段时间需要临时统计是否接种疫苗直接往JSON里塞一个字段就行不需要改表结构。3.2 组织表用通用树而不是写死层次这里我重点说一下组织表的设计因为这是适配不同场景的关键。如果系统给学校用组织层次是学校/院系/班级给社区用是街道/小区/楼栋给企业用是公司/部门/小组。一旦写死某套层次换场景就要重构。我用一张自关联表解决CREATE TABLE t_org ( id BIGINT PRIMARY KEY AUTO_INCREMENT, parent_id BIGINT DEFAULT 0, org_name VARCHAR(128) NOT NULL, org_type TINYINT DEFAULT 1 COMMENT 1-学校 2-社区 3-企业, sort_order INT DEFAULT 0, create_time DATETIME, update_time DATETIME );查询某个组织及其所有子组织时我先在Java里递归查出所有子节点ID集合再丢给Mapper做范围查询。虽然对超大数据量不友好但对这类项目的规模绰绰有余而且逻辑清晰任何人都能维护。3.3 索引和字段类型的小心机所有时间字段用DATETIME而不用TIMESTAMP避免2038年问题统一存北京时间避免部署到海外云主机出现时区混乱。状态字段用TINYINT而不是VARCHAR比如handle_status用0待处理、1处理中、2已完成代码里定义枚举常量减少字符串拼写错误。统计类查询基本都走report_date和org_id所以健康记录表的时间字段单独建索引不需要为了统计再建冗余汇总表。身份证号、手机号等字段虽然在业务上要查重但不需要模糊搜索索引类型用普通B-Tree就行。4. 后端落地从SpringBoot2骨架到一个每天能扛住高频上报的服务4.1 项目结构与依赖清单我采用的包结构比较标准但每个包职责分明com.example.health/ ├── controller/ # 接口层只做参数接收和结果返回 ├── service/ # 业务逻辑层 ├── mapper/ # MyBatis-Plus Mapper接口 ├── entity/ # 数据库实体 ├── dto/ # 请求/响应对象 ├── config/ # 配置类跨域、拦截器、MyBatis-Plus配置 ├── common/ # 统一响应、异常处理、常量 └── util/ # 工具类JWT、日期依赖方面核心就这五个dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-boot-starter/artifactId version3.5.3.1/version /dependency dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId version8.0.33/version /dependency dependency groupIdcom.auth0/groupId artifactIdjava-jwt/artifactId version4.4.0/version /dependency dependency groupIdorg.projectlombok/groupId artifactIdlombok/artifactId /dependency4.2 统一响应体和全局异常处理是最值得抄作业的设计所有接口返回同一个结构体前端axios拦截器才好统一处理这也是中后台项目最基本的要求Data public class ResultT { private Integer code; private String message; private T data; public static T ResultT success(T data) { ResultT r new Result(); r.code 200; r.message success; r.data data; return r; } public static T ResultT error(Integer code, String message) { ResultT r new Result(); r.code code; r.message message; return r; } }全局异常处理配合业务异常类能把校验逻辑从Controller里解放出来。比如打卡时发现今天已经打过卡Service直接抛一个业务异常if (alreadyReportedToday) { throw new BizException(500, 今日已上报请勿重复提交); }全局异常处理器捕获后统一返回错误结构前端拿到code ! 200就提示错误信息。这样Controller里不需要任何try-catch代码特别干净。4.3 JWT鉴权登录后如何保护接口管理系统的痛点在于健康打卡、异常处理这些操作都涉及个人隐私数据不能让任何人都能随意调用。我用JWT做无状态鉴权Redis暂时都不需要引入降低项目复杂度。流程如下用户登录成功后服务端用HMAC256算法签发Token里面包含userId和role设置24小时过期。前端把Token放在请求头Authorization里。后端写一个AuthInterceptor拦截器放行登录接口和静态放行路径其余接口一律校验Token有效性。校验不通过直接返回401前端收到后跳转登录页。这里有个容易被忽略的点HandlerInterceptor里能拿到的Token信息要放进ThreadLocal或者RequestContextHolder这样Service层也能随时拿到当前登录人ID做我的打卡列表我的待办异常这类个性化查询。4.4 MyBatis-Plus的几个实用配置MyBatis-Plus默认是单表CRUD但有几个配置不调会踩坑分页插件必须显式配置否则Page查询不会真正分页而是把全表数据查出来内存分页。配置一个MybatisPlusInterceptor并添加PaginationInnerInterceptor即可。逻辑删除我给t_person表加了deleted字段通过TableLogic注解实现逻辑删除。这样人员退出系统后历史健康记录依然保留统计数据不受影响。自动填充create_time和update_time通过MetaObjectHandler自动填充业务代码里完全不出现当前时间赋值。代码生成器MyBatis-Plus的代码生成器在这里是神器一张表对应一个实体、Mapper、Service可以直接生成。但生成后要自己Review比如删除没用的xml文件和多余注解。4.5 统计报表接口是怎么写出来的统计逻辑基本都是按组织分组 按日期范围 条件聚合我给大家看一个核心SQLselect idstatAbnormalByOrg resultTypemap SELECT t2.org_name AS orgName, COUNT(DISTINCT t1.person_id) AS abnormalPersonCount FROM t_health_record t1 LEFT JOIN t_person p ON t1.person_id p.id LEFT JOIN t_org t2 ON p.org_id t2.id WHERE t1.abnormal 1 AND t1.report_date BETWEEN #{startDate} AND #{endDate} GROUP BY t2.id ORDER BY abnormalPersonCount DESC /select这类SQL没有技术难度但有一个性能细节涉及多表时必须在WHERE条件的字段上建索引。实际开发中如果时间筛选范围超过三个月我会用MySQL8.0的COUNT(DISTINCT ...)窗口函数做预聚合把结果缓存到一张统计表中页面加载直接查缓存表体感速度会快很多。5. 前端Vue3开发从工程搭建到完整的管理后台5.1 Vite工程与请求封装前端我用Vite搭建相比Webpack冷启动和热更新速度都快非常多。工程里核心依赖是Vue3、Vue Router、Pinia、Axios、Element Plus、ECharts。Axios请求封装必须做否则每个组件都写一遍错误处理会疯掉。我的封装思路是const service axios.create({ baseURL: import.meta.env.VITE_API_BASE || /api, timeout: 10000 }) service.interceptors.request.use(config { const token localStorage.getItem(token) if (token) { config.headers.Authorization Bearer ${token} } return config }) service.interceptors.response.use( response { const res response.data if (res.code ! 200) { ElMessage.error(res.message || 请求失败) if (res.code 401) { router.push(/login) } return Promise.reject(new Error(res.message)) } return res.data }, error { ElMessage.error(error.message || 网络异常) return Promise.reject(error) } )这一步做完业务组件里永远不需要关心错误处理只需要写const list await getHealthList(params)这样的调用。5.2 登录与动态路由登录后我根据用户的角色动态生成可访问菜单。具体做法是后端返回当前用户的角色和权限码前端在router.beforeEach守卫里根据权限码过滤路由表。这里得提醒一个细节Vue3的router.addRoute添加动态路由后页面刷新会导致路由丢失。解决办法是刷新后先拉取用户信息再重新挂载路由同时用Pinia或localStorage缓存用户权限信息。5.3 健康打卡表单的交互细节打卡表单是使用频率最高的页面交互上有三个重点默认值打开页面时自动从个人档案中回显姓名、组织体温这格不填等用户输入避免误传默认值。重复提交拦截前端通过查询今日记录接口如果已打卡则展示结果卡片不再显示表单按钮后端又有联合唯一索引兜底双保险。异常联动提示体温输入超过37.3时页面提示该体温属于异常范围将生成异常记录让用户有心理预期。5.4 统计页面数据可视化的实现Vue3 ECharts的组合已经是标配。为了不在组件里堆砌图表配置代码我把每个图表封装成一个组件传option就渲染template div refchartRef styleheight: 400px/div /template script setup import * as echarts from echarts import { onMounted, onBeforeUnmount, watch, ref } from vue const props defineProps({ option: { type: Object, required: true } }) const chartRef ref(null) let chart onMounted(() { chart echarts.init(chartRef.value) chart.setOption(props.option) window.addEventListener(resize, chart.resize) }) watch(() props.option, (newOption) { chart.setOption(newOption) }, { deep: true }) onBeforeUnmount(() { window.removeEventListener(resize, chart.resize) chart.dispose() }) /script页面里组合一个折线图展示每日上报率、一个柱状图展示各组织异常人数分布后端一个接口就够了。6. 联调与部署最容易翻车的六个典型坑开发到上线我遇到很多预料中的坑挑典型的六个列出来大家能避开尽量避开。6.1 MySQL8.0驱动的连接配置MySQL8.0连接串里有三个点必须注意驱动类名是com.mysql.cj.jdbc.Driver不是旧的com.mysql.jdbc.Driver。URL必须加serverTimezoneAsia/Shanghai否则驱动拿服务器默认时区查询出来的时间会和北京时间差8小时。建议加useUnicodetruecharacterEncodingutf8防止中文乱码。我最终用的连接串是这样spring: datasource: url: jdbc:mysql://localhost:3306/health_system?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/ShanghaiuseSSLfalseallowPublicKeyRetrievaltrue username: root password: yourpassword driver-class-name: com.mysql.cj.jdbc.DriverallowPublicKeyRetrievaltrue这个参数很容易被忽略MySQL8.0默认的caching_sha2_password认证方式在某些客户端连接时会有报错加上这个参数能解决。6.2 跨域CORS配置前后端分离开发时Vite默认在5173端口后端在8080端口一开始肯定会被CORS拦。我在后端的配置类里加了一个全局的CORS过滤器Configuration public class CorsConfig { Bean public CorsFilter corsFilter() { CorsConfiguration config new CorsConfiguration(); config.addAllowedOriginPattern(*); config.addAllowedHeader(*); config.addAllowedMethod(*); config.setAllowCredentials(true); config.setMaxAge(3600L); UrlBasedCorsConfigurationSource source new UrlBasedCorsConfigurationSource(); source.registerCorsConfiguration(/**, config); return new CorsFilter(source); } }生产环境建议把allowedOriginPattern收紧成实际域名不要用通配符安全第一。6.3 前端刷新404和history路由问题我一开始用createWebHistory模式部署到Nginx后直接刷新某个二级路由页面发现报404。原因是Nginx只配置了/指向index.html刷新时找不到对应的路径。解决方法是Nginx里加一个try_files规则server { listen 80; server_name example.com; root /usr/share/nginx/html; index index.html; location / { try_files $uri $uri/ /index.html; } }如果不想处理Nginx配置直接用createWebHashHistory可以规避但URL会带#看大家取舍。6.4 MyBatis-Plus分页插件忘配置导致的假分页分页查询返回的total一直等于总条数页面翻到第二页数据却没变化。排查了半小时发现是PaginationInnerInterceptor没注册。正确的配置方式Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor new MybatisPlusInterceptor(); interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); return interceptor; }注意DbType.MYSQL不能省不指定分页SQL可能不生成。6.5 Vue3中reactive响应式丢失在Vue3里直接对reactive数组做arr newArr赋值会把响应式引用丢掉。列表重置时我一开始用list []结果页面不刷新。正确的写法是const state reactive({ list: [] }) state.list.splice(0, state.list.length, ...newList)或者在ref下用list.value newList都行。这类细节看文档根本记不住踩过一次就长记性了。6.6 空指针别来NPE日期参数格式要明确前端传日期范围给后端比如2024-05-01后端如果直接Date接收容易报转换异常。我的做法是前端统一传String类型后端用DateTimeFormat(pattern yyyy-MM-dd)解析接收后再转LocalDate查询。尽管麻烦一点但用户输入永远是不可信的做好边界校验才是后端持久稳定的基础。7. 文档与源码交付真正拉开项目档次的是这几个细节标题里写着含文档。很多同学觉得文档就是凑字数但实际上面试官或答辩老师看源码之前大概率先翻文档。文档质量直接决定项目的专业度。7.1 一份能落地的系统文档应包含什么我最终交付的文档目录大概是这样的需求分析用户角色、功能清单、业务流程描述。不用写特别长但用例要清楚。数据库设计每张表的意义、关键字段说明、ER关系描述。配合SQL脚本别人拿到就能初始化表。接口文档每个接口的URL、请求参数、响应示例。我推荐大家用Swagger/SpringDoc注解后自动生成也可以直接导出Markdown。部署手册从安装JDK、MySQL8.0到后端打Jar包、前端构建打包、Nginx配置的每一步。这一步是很多人忽略的但交付给没有环境的人时部署手册才是救命稻草。更新日志方便回溯每次改了什么。7.2 源码可维护性的一点体会我在这套系统里刻意避免神仙代码比如Service层不做任何炫技每类业务对应一个Service字段命名全部见名知意。实际维护过这套代码的人基本都能在十分钟内上手因为数据流清晰Controller接收参数Service做校验和业务组合Mapper做数据访问异常统一抛给全局处理器。对想拿这套源码做课程设计或毕业设计的同学我建议拿到后先按照文档把环境跑起来再去改业务字段。哪怕只是把健康记录的字段从体温、咳嗽改成其他业务属性或者把组织树的类型换一下练习效果都比从零抄一遍代码好。写在最后的个人体会这套项目做下来我最深的体会是技术栈本身没有多吸引人真正花时间的永远是需求梳理和边界控制。SpringBoot2 Vue3 MyBatis-Plus MySQL8.0这套组合其实已经能支撑大部分管理系统的开发甚至在很长一段时间里都够用。而把复杂业务拆成可维护的模块、用文档把思路沉淀下来才是这类项目交付价值最核心的部分。最后再分享一个实用小技巧数据库结构和接口文档尽量在开发中期就同步维护别等到全部做完再补。我这次就是边开发边用Swagger管理接口项目上线前文档几乎零成本生成省出来的时间都用来调细节了。如果你正在规划类似的项目真心建议把文档即代码当成习惯——它帮你自己的复盘也帮后来接手的人省下大把时间。