
先说个背景。前年我帮一个民办养老机构做信息化调研发现他们还在用Excel登记长者档案、手写用药记录、口头交接班。后来陆续看过不少市面上的养老管理系统要么太贵要么功能跟实际管理流程对不上。最后我们自己做了一套基于SpringBootVue的大健康养老公寓管理系统——后端用的是SpringBootMyBatisMySQL这一套非常成熟的组合前端用Vue做单页应用。这套系统跑通之后从长者健康档案、护工排班、用药提醒到家属端查看报告整条链路都被管起来了。这篇博客我把整个项目的设计思路和关键技术点完整拆开讲一遍。内容包括养老业务模块怎么拆解、SpringBoot后端如何落地MyBatis缓存和分页插件、Vue前端的路由权限与可视化方案、MySQL表结构和慢查询优化、以及从零跑通源码到上线排查的完整过程。无论是拿来做课程设计、毕业设计还是想在公司内部快速搭一套内部管理系统这份记录都能直接作为参考。1. 养老公寓管理系统到底要管什么需求拆解与模块边界很多人拿到这类题目就急着建表写接口结果做完才发现业务模型是错的。养老公寓管理系统不是普通的增删改查它本质上是一个以长者的健康生命周期为主线的运营管理系统。我先把业务侧的关键角色和核心流程理清楚后面做表结构和接口设计才有依据。1.1 三端协同的业务模型一套完整的养老公寓管理系统至少要覆盖三类用户管理端、护工端、家属端。管理端关心的是公寓运营——床位入住率、费用收缴、护工排班、安全巡检护工端关心的是日常执行——健康数据录入、用药执行、交接班记录家属端关心的是知情——长者的实时状态、健康趋势、异常告警。这三类角色对数据的读写权限完全不一样接口设计时一定要用JWT或者Sa-Token做角色鉴权而不是靠前端隐藏按钮。我实际调研时发现很多机构连长者档案这个基础数据都维护得很乱有的记录在纸质表有的在微信群聊天记录里。所以系统里我把长者档案设计成一条主档案多份健康子表的模式主档案记录姓名、身份证号、床位号、紧急联系人、入住日期健康子表分别存血压、血糖、心率、体温等周期性数据。这样才能支持后续的健康趋势分析和异常预警。1.2 核心功能模块清单与优先级做管理系统最忌一上来什么都想做。我给这个系统定的第一版功能清单如下按优先级排序长者档案管理入住、转床、退住全流程支持批量导入Excel导出健康报告PDF。健康监测与预警对接手动录入与设备采集定时任务扫描异常阈值触发短信/站内信告警。用药管理医嘱录入、用药计划、护工执行打卡逾期未执行自动上报。护工排班按班次早/中/晚排班支持换班申请审批。床位与费用管理可视化房态图、月度账单自动生成。家属端微信小程序或H5页面查看健康周报、接收异常通知。系统管理用户、角色、菜单、操作日志。凡是做过真实项目的人都知道最后真正耗时最多的往往不是业务接口本身而是报表统计和权限控制。比如月度费用里水电费分摊、护理等级变更后的差价补退这些规则都藏在业务细节里。开发时一定要先跟业务方把规则确认清楚再动手。1.3 需求蔓延的控制方法这种管理系统最怕的就是需求蔓延。我遇到过客户在验收前突然提出加一个视频探视功能这个需求如果前期没有预留扩展点改动量会非常大。所以项目启动时我就定了一个原则所有跨端、跨模块的功能都走接口层解耦。比如视频探视后面独立成一个服务通过iframe嵌入到网页端再比如对接智能手环的实时心率也是走消息队列异步写入不直接耦合到业务接口里。这样模块边界清晰后期扩展不会牵一发动全身。2. 后端落地SpringBoot MyBatis MySQL的工程化细节后端的整体技术栈我没有追新选的是SpringBoot 2.7.x MyBatis MySQL 8.0再加一个Redis做缓存和验证码存储。有人会问为什么不用MyBatis-Plus这里说明一下项目里很多统计查询是手写SQLMyBatis的原生Xml映射更灵活而且这套系统如果作为课设/毕设展示用原生MyBatis反而更能体现SQL功底。2.1 版本选择背后的坑先说SpringBoot版本。2025年SpringBoot 3.x已经非常普及了但如果你拿到的代码是2.7.x千万别着急升级。SpringBoot 3基于Jakarta EE包名从javax.换成了jakarta.一旦升级所有import javax.servlet的代码、以及大部分旧版第三方starter全部要改。我见过太多人因为版本太高导致启动直接报ClassNotFoundException。如果不需要用GraalVM原生镜像、不需要JDK17的新特性2.7.x依然是当前最稳的选择。MySQL驱动也一样。MySQL 8.x必须用com.mysql.cj.jdbc.Driver老项目里常见的com.mysql.jdbc.Driver在新版本驱动里已经移除了。连接串里还要显式配置时区spring: datasource: url: jdbc:mysql://localhost:3306/health_apartment?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/ShanghaiuseSSLfalseallowPublicKeyRetrievaltrue username: root password: 123456 driver-class-name: com.mysql.cj.jdbc.DriverallowPublicKeyRetrievaltrue这个参数是MySQL 8.0.12之后连接用户密码认证时用到的不配的话部分环境下会报Public Key Retrieval is not allowed。2.2 MyBatis缓存机制与一次实测事故MyBatis缓存是我在面试里经常被问、在实际项目里也真正踩过坑的点。它分两级一级缓存是SqlSession级别的默认开启二级缓存是namespace级别的需要手动开启。这个项目中我给健康监测表的Mapper开了二级缓存CacheNamespace(eviction LruCache.class, flushInterval 60000, size 512, readWrite true) public interface HealthRecordMapper { ListHealthRecord selectListByElderId(Param(elderId) Long elderId); }但这套机制有个非常大的隐患如果两个表在业务上有联查但分布在两个Mapper里二级缓存很容易出现脏数据。比如我先查长者信息再通过HealthRecordMapper插入一条健康记录理论上如果ElderMapper的二级缓存里有相关联的冗余数据就会造成不一致。我这里说一个实际发生的事故系统里做了个最近一条健康记录的统计查询因为开了二级缓存第一次查出来的数据被缓存了。随后护工新录入了数据但查询结果还是旧的家属端看到的健康报告始终少了一条。排查了很久最终定位到是二级缓存没有在insert/update/delete时自动清空跨namespace缓存导致的。所以我的建议是这个项目里直接关闭二级缓存或者只在极少变更的表上开启。一级缓存够用配合Redis做热点数据的缓存可控性要高得多。如果你非要演示MyBatis二级缓存记得手动调用sqlSession.clearCache()并且不要在关联查询上依赖它。2.3 MyBatis分页插件的原理与正确用法分页几乎是管理系统的标配。MyBatis原生分页要手写LIMIT麻烦且容易出错。我用的PageHelper是市面上最主流的方案。用法很简单PageHelper.startPage(pageNum, pageSize); ListHealthRecord list healthRecordMapper.selectPageList(new LambdaQueryWrapper()); PageInfoHealthRecord pageInfo new PageInfo(list);但注意一个核心原理PageHelper.startPage()只是往ThreadLocal里塞了一个分页参数下一次执行的第一个Mapper查询会被拦截器改写为带LIMIT的SQL。所以它后面必须紧跟着一条查询语句中间不能有其他Mapper操作或调用其他service方法。我见过有人把startPage和查询之间加了一个日志保存操作结果分页效果完全没生效接口返回了全量数据。PageHelper拦截器的实现原理是Interceptor接口通过动态代理拦截Executor.query()然后利用jsqlParser重写SQL。如果你在代码里手写了ORDER BY和子查询要留意它拼接COUNT语句时的性能。针对大表建议用countSql参数自定义count语句而不是让它默认去解析。2.4 全局XSS过滤器与PDF上传的对抗安全这块很多人图省事只做参数校验忽略了XSS攻击。我在这套系统里写了一个全局过滤器核心思路是包装HttpServletRequest对请求参数做HTML转义。但有一个特殊场景需要注意上传PDF文件时请求体是二进制流不能像普通form参数那样直接转义。具体做法是写一个OncePerRequestFilter在过滤器里读取输入流判断Content-Type。如果是application/pdf或multipart/form-data文件流则直接放行不进入XSS转义逻辑如果是JSON或表单参数则对key和value做白名单校验和转义。相关片段如下Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) { String contentType request.getContentType(); if (contentType ! null (contentType.contains(multipart/form-data) || contentType.contains(application/pdf))) { chain.doFilter(new XssRequestWrapper(request, false), response); return; } chain.doFilter(new XssRequestWrapper(request, true), response); }为什么要单独放行文件流因为如果你用字符流方式读取二进制PDF轻则字节丢失导致文件损坏重则遇到特殊字节序列直接抛MalformedInputException。这个坑是真实的我之前在另一套系统里犯过排查到凌晨才定位是过滤器把文件流破坏了。2.5 事务边界与统一异常体系多表写入必须加事务比如长者退住要同时更新长者状态、释放床位、生成退住费用记录任何一个环节失败都不能留下半截数据。Spring里声明式事务最简单的用法是Transactional(rollbackFor Exception.class)。注意一定要指定rollbackFor因为Spring默认只在RuntimeException和Error时才回滚普通异常默认不触发回滚。事务失效的情况同样要留意同类内部方法调用this.save()事务不生效方法不是public不生效数据库引擎是MyISAM不生效异常在方法内被try-catch吞掉了不生效。全局异常处理我用RestControllerAdvice统一接管自定义业务异常ServiceException给前端返回错误码和提示信息其他未知异常统一记日志并返回系统繁忙。3. 前端实践Vue核心场景与数据可视化实现前端我用的是Vue 2.7 Element UI ECharts工程化构建工具用的Vue CLI。为什么不用Vue 3因为这套系统有不少现成组件依赖以及代码仓库里很多同事更熟悉Vue 2的选项式API。如果你的目标是新项目且团队没有历史包袱Vue 3 Vite是推荐方向。但无论如何下面要讲的几个核心场景在两者中思路是通用的。3.1 工程初始化与Node环境配置前端项目初始化时最容易被坑的是Node版本。Vue CLI 5要求Node 12以上但如果Node版本高于17部分依赖包比如node-sass编译时会报错。现在更推荐用sass的dart-sass实现配合Vue CLI的vue.config.js里配置css.loaderOptions。另外开发环境下前端访问后端接口一定要配代理否则跨域问题会烦死你// vue.config.js module.exports { devServer: { port: 8081, proxy: { /api: { target: http://localhost:8080, changeOrigin: true, pathRewrite: { ^/api: /api } } } } };配好之后前端请求/api/health/list就直接转发到后端8080端口不会出现跨域。生产环境则由Nginx统一做反向代理前端只需要请求相对路径。3.2 路由守卫与动态权限菜单管理系统的路由不能全量写在静态路由表里。我是基于后端返回的菜单权限动态添加路由的。具体做法是登录成功后请求/system/menu/list接口拿到菜单树前端用router.addRoutes()动态挂载。路由守卫是权限控制的第一道价值点router.beforeEach((to, from, next) { const token localStorage.getItem(token); if (!token to.path ! /login) { next(/login); return; } if (token to.path /login) { next(/); return; } next(); });这里有一个分页或者列表页面常见的坑从列表页跳详情页再返回时列表状态丢失。Vue里可以借助keep-alive缓存组件实例配合路由meta配置keep-alive router-view v-if$route.meta.keepAlive / /keep-alive列表页的currentPage和搜索条件在activated钩子里恢复。如果没做这一步护工在翻到第8页查看健康记录点进去再退回来页面直接跳回第1页操作体验很差。3.3 axios封装与token失效处理axios封装是老生常谈但有几个细节值得注意。第一请求拦截器里加Token第二响应拦截器里统一处理HTTP状态码和业务状态码第三401时清除本地凭证并跳转登录页。service.interceptors.response.use( response { const res response.data; if (res.code ! 200) { this.$message.error(res.msg || 系统异常); return Promise.reject(new Error(res.msg || Error)); } return res; }, error { if (error.response error.response.status 401) { localStorage.clear(); window.location.href /login; } return Promise.reject(error); } );实测中我发现一个不常见但很真实的问题后端接口返回的JSON字段命名是驼峰前端定义的数据模型是下划线两者不一致导致详情页数据渲染为空。这个项目里后端统一配置了MyBatis的map-underscore-to-camel-case所以数据库下划线字段能自动映射到驼峰属性。前端拿到的数据就是驼峰格式千万保持前后端字段风格一致不要一会儿下划线一会儿驼峰。3.4 ECharts健康趋势与m3u8视频流接入健康监测页面的核心是趋势图和预警列表。ECharts展示心率、血压趋势折线图数据缩放组件基本够用。要注意的是7日/30日时间粒度的切换数据量大会导致接口响应慢所以我在前端做了时间范围必选后端SQL里用DATE_FORMAT按天分组避免一次性查全量数据。视频探视这块我用的是HLS协议也就是m3u8格式视频流。在Vue里播放m3u8有两个方案video.js加videojs-contrib-hls插件或者用hls.js。我用的后者代码比较干净import Hls from hls.js; if (Hls.isSupported()) { const hls new Hls(); hls.loadSource(videoUrl); hls.attachMedia(videoEl); }为什么用HLS而不是RTMP因为RTMP依赖Flash已经彻底废了而HLS基于HTTPNginx直接可以分发天然穿透防火墙移动端H5也支持得非常好。拉流地址通常是http://ip:8080/live/room1.m3u8这种格式。如果是测试环境没有真实摄像头可以直接用FFmpeg推一路本地视频文件模拟直播流。4. 数据库设计从表结构到性能优化的完整链路数据库是这个系统的灵魂。SpringBoot和Vue代码写得再漂亮表结构设计不合理上线两周就会暴露各种性能问题。我以这个项目里最核心的几张表为例把设计思路、索引和常见的坑一次讲清楚。4.1 核心表结构设计思路第一张是长者档案表elder_info。这张表是全局最基础的表几乎所有业务表都外键关联它的elder_idCREATE TABLE elder_info ( id BIGINT NOT NULL AUTO_INCREMENT COMMENT 主键, name VARCHAR(32) NOT NULL COMMENT 长者姓名, id_card VARCHAR(18) DEFAULT NULL COMMENT 身份证号, gender TINYINT DEFAULT 1 COMMENT 1男 2女, birthday DATE DEFAULT NULL COMMENT 出生日期, bed_no VARCHAR(20) DEFAULT NULL COMMENT 床位号, nursing_level VARCHAR(20) DEFAULT 自理 COMMENT 护理等级, check_in_date DATETIME DEFAULT NULL COMMENT 入住时间, status TINYINT DEFAULT 1 COMMENT 1在住 2退住 3外出, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_bed_no (bed_no), KEY idx_status (status) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT长者档案表;第二张是健康监测记录表health_record。这张表的数据量增长极快一天每个长者可能生成10多条记录一个月就是几万条。所以它一定是按时间维度查询最频繁的表。我给它建了联合索引(elder_id, record_time)同时record_time单独建索引用于按日期统计CREATE TABLE health_record ( id BIGINT NOT NULL AUTO_INCREMENT, elder_id BIGINT NOT NULL COMMENT 长者ID, record_type TINYINT DEFAULT 1 COMMENT 1血压 2血糖 3心率 4体温, record_value VARCHAR(32) DEFAULT NULL COMMENT 测量值, unit VARCHAR(16) DEFAULT NULL COMMENT 单位, record_time DATETIME NOT NULL COMMENT 测量时间, operator_id BIGINT DEFAULT NULL COMMENT 录入人, remark VARCHAR(255) DEFAULT NULL COMMENT 备注, PRIMARY KEY (id), KEY idx_elder_time (elder_id, record_time), KEY idx_record_time (record_time) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT健康监测记录表;第三张是用药计划与执行表medication_plan和medication_log。计划表关联医嘱执行表记录每一次实际用药状态状态包括待执行、已执行、已跳过、已逾期。这两张表的设计重点在于执行表必须用plan_id建立外键关联并且在plan_id和execute_time上建联合索引否则每日统计逾期时必然全表扫描。4.2 一个真实的慢查询排查案例MyBatis Update执行慢在测试阶段我发现一个接口性能问题medicationLogMapper.updateStatus(Param(id) Long id, Param(status) Integer status)这个更新接口在数据量只有5万条的时候执行时间达到了1.8秒。这在开发环境完全不能接受。排查步骤是这样的先在MySQL命令行手动执行同样的UPDATE发现执行很快毫秒级怀疑是MyBatis配置问题检查了jdbc连接串没发现问题持续观察几次后发现每次慢查询都发生在特定时间段正好和我做批量插入压测的时间吻合用SHOW ENGINE INNODB STATUS查看锁等待信息发现大量LOCK WAIT状态。根因是批量插入和更新是同一张表的不同Mapper方法由于DEFAULT的事务隔离级别是REPEATABLE READ批量插入事务持有间隙锁导致UPDATE等在行锁上。解决方案有两个方向在业务允许的情况下批量插入分批提交避免长事务持有锁把批量插入方法加上Transactional但控制每次插入的条数比如1000条一批。另外还有一个隐藏极深的坑MyBatis的Update注解执行慢但XML里同样SQL执行快。原因往往是注解方式的参数映射和预编译处理机制不同导致没走索引。比如你在注解里直接写#{id}但实际传入的id是一个字符串类型的数字MySQL会隐式转换导致索引失效。解决方法是传入参数时强转成Long或者在SQL里显式CAST(#{id} AS SIGNED)。4.3 MySQL8关键配置与建表规范MySQL 8默认的认证插件是caching_sha2_password老项目用mysql-connector-java 5.x连不上一定要换8.x驱动。字符集统一用utf8mb4因为utf8在MySQL里最多只有3字节存不了emoji和部分生僻字。排序规则建议用utf8mb4_general_ci性能略好。sql_mode交由严格模式是默认状态其中NO_ZERO_DATE和NO_ZERO_IN_DATE会导致你无法把日期设置为0000-00-00。但有些旧系统里常用默认值为0来表示未知日期如果你在Mapper里INSERT时漏掉日期字段就可能报错。处理方式是SET sql_mode STRICT_TRANS_TABLES,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION;把NO_ZERO_DATE去掉允许日期为0000-00-00。但更规范的写法是日期字段都允许NULL代码里用null表示缺失这样就不会触发这个问题。建表方面我有几个推荐规范所有业务表必须有id、create_time、update_time三个基础字段金额字段用DECIMAL(10,2)不要用FLOAT/DOUBLE避免精度丢失逻辑删除字段统一叫deletedTINYINT类型默认0参与查询的字段尽量建索引但索引不是越多越好写多读少的日志表只需要主键索引。5. 从源码到跑通部署步骤与高频坑位排查很多人拿到一套SpringBootVue的源码后第一步就卡在项目跑不起来。这里我把自己从零部署这套系统的完整步骤和遇到的高频坑位写出来照着做基本半小时内能本地跑通。5.1 本地运行的完整步骤第一步初始化数据库。在MySQL里创建数据库CREATE DATABASE health_apartment DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;然后导入项目自带的health_apartment.sql。如果源码包里没带SQL文件就根据实体类手动建表顺序是先建长者表、床位表再建健康记录、用药、排班等依赖表。第二步修改后端配置。打开application.yml改数据库账号密码。注意Redis如果没装先临时把Redis相关配置注释掉否则启动直接报连接拒绝。第三步启动后端项目。Maven项目中IDEA里直接运行HealthApartmentApplication.java控制台出现Started日志说明成功。第四步安装前端依赖。进入前端目录cd health-apartment-ui npm install --registryhttps://registry.npmmirror.com npm run serve如果node_modules装得慢或报错一定要加--registry指定国内镜像。装完依赖跑起来之后浏览器访问http://localhost:8081登录页出来就说明工程跑通了。5.2 高频报错与解决方案我把这套系统在部署及运行中最常见的问题列成一个对照表建议保存现象根因解决方案后端启动报ClassNotFoundException: javax.servlet.FilterJDK版本过高或SpringBoot版本不匹配确认JDK8对应SpringBoot2.xJDK17对应3.x不要混用Access denied for user rootlocalhost数据库密码错误或远程登录权限未开确认application.yml中的账号密码本地测试用root密码Unknown database health_apartment数据库没建或名字不对先执行CREATE DATABASEPublic Key Retrieval is not allowedMySQL8连接串缺少参数连接串加allowPublicKeyRetrievaltrue前端npm install大量报错Node版本与依赖不兼容使用Node 14.18~16.x删掉node_modules和package-lock.json重装验证码不显示后端Session跨域或Redis未启动确认Redis已启动开发环境前后端联调用同一个域名代理上传Excel/PDF时后端收到空文件过滤器拦截了文件流参考2.4节内容类型放行列表接口慢内存飙升分页没生效或全表扫描检查PageHelper的位置是否紧邻Mapper查询用EXPLAIN分析SQL接口返回JSON parse error前端传了多余字段或实体类日期格式与JSON不一致加JsonFormat(pattern yyyy-MM-dd HH:mm:ss)前端核对字段命名5.3 上线前的安全自查清单系统上线之前我建议做一轮自查很多隐患在开发环境根本发现不了SQL注入不要用${}拼接SQL全部改#{}预编译。如果做动态排序白名单校验排序字段。越权访问所有接口必须做登录校验涉及长者的接口要做数据权限拦截防止护工A看到护工B负责的长者信息。敏感信息泄露登录接口返回不要带密码字段日志中不要打印完整身份证号、手机号做脱敏处理。密码安全密码存储用BCrypt加密不要用MD5。MD5加盐仍然不够安全BCrypt有自适应的计算成本。操作日志关键操作退住、费用调整、医嘱修改记录操作人和操作内容方便事后审计。6. 我做这套系统时最有价值的几个设计决策最后聊几点偏经验型的东西不算教程但希望在做同类系统时能帮你少走一段路。一是不要把健康监测简单做成录入展示。健康数据真正的价值在于趋势和异常预警。我在系统里加了一个定时任务每天凌晨统计每位长者的血压、血糖波动幅度如果连续三天超标自动生成一条护理关注提醒。这一功能做出来后护理部主任反而比家属端更依赖它因为它能把被动发现变成主动干预。二是接口设计要提前考虑家属端接入。很多教材里的Controller喜欢把业务逻辑直接写在Controller里方便是方便但后续如果要给微信小程序、App端提供同样的接口这种写法就得重构。我把Service拆成业务层Controller只做参数校验和响应封装。这样同一个Service方法可以同时暴露给Web端、小程序端。三是前端列表页的性能优化优先级高于代码花活。健康记录列表如果数据量大分页查询慢第一时间别急着上虚拟滚动。先用EXPLAIN看SQL有没有走联合索引再看有没有不必要的N1查询。很多时候问题出在MyBatis的resultMap联查上点一次列表后台发了十几条SQL这才是性能瓶颈。四是留好数据备份和迁移通道。系统上线三个月后你会感谢自己当初把业务数据表设计得足够规范。我用DBeaver每周自动备份一次数据库同时把长者档案、健康记录、用药记录三张核心表做了按月分表的预留设计。分表不一定要一开始就做但表结构要支持按时间字段归档。最后再分享一个小技巧本地调试时在application.yml里把MyBatis的SQL日志打开配合格式化输出工具查看每一条SQL排查Update执行慢、分页异常这类问题会高效得多logging: level: com.health.apartment.mapper: debug把mapper包日志级别调成debug之后MyBatis会把预处理SQL和参数值都打到控制台一眼就能看出SQL是否走了索引、参数类型是否隐式转换。这个习惯我用了很多年每次接手新项目第一件事就是开SQL日志比任何调试工具都直接。