
做敬老院管理系统这种项目很多人第一反应是“业务不复杂好像没什么好写的”。但真正把一个前后端分离的敬老院管理系统从0到1搭出来并且顺利部署上线你会发现它其实覆盖了开发日常中几乎所有会遇到的环节需求拆解、表结构设计、接口约定、权限控制、跨域联调、打包部署。说它是练手项目足以练到位。这篇文章我就围绕“SpringBootVueMyBatisMySQL”这套组合把一个敬老院管理系统从设计到落地再到部署的完整过程讲清楚包括我实际做的时候踩过的坑和一些觉得可以少走弯路的经验。不管你是大三准备实习想找个拿得出手的项目还是初入行想搞懂前后端分离的部署流程都可以对照着看。1. 项目定位与技术选型思路1.1 敬老院管理系统到底在做哪些事敬老院管理系统本质上是面向养老机构内部管理的业务系统。我按真实场景拆了一下核心业务可以分成下面几条线。老人全生命周期档案入院登记、健康档案、护工照护记录、家属联系人等。这部分是系统的地基所有后续业务都要围绕老人ID展开。床位资源管理房间楼栋管理、床位分配与调整、入住率统计。养老机构的床位相当于酒店的客房状态管理非常关键。护理与排班护理员值班安排、照护任务分配、交接班记录。这是日常使用频率最高的功能护理员每天都要打卡式填写。费用与账单押金、床位费、护理费、餐费的缴纳记录和欠费提醒。财务模块虽然简单但字段必须设计严谨涉及金额的字段一律用Decimal。日常服务记录餐饮配送、活动参与、用药提醒、访客登记。这些散点数据汇总起来能做成老人状态画像。每一条线单独看不复杂合在一起就构成了一个完整的业务闭环。这也是它适合做前后端分离实战项目的原因——你不能靠一个静态页面糊弄必须有前后端数据的真实流转。我当时做这个项目的时候最深的感受是业务需求不是靠想出来的而是靠“走一遍流程”捋出来的。你把自己想象成护理员、前台、财务、院长分别去使用系统功能自然就浮出水面了。1.2 为什么是SpringBootVueMyBatisMySQL技术选型这部分很多人会纠结要不要上微服务、要不要用Redis做缓存。我的建议很直接这类业务体量用SpringBootVueMyBatisMySQL这套组合是最稳的。SpringBoot的价值在于自动配置和内嵌Tomcat你不需要像SSH时代那样写一大堆XML配置。搭一个能跑起来的后端工程十分钟之内搞定。而且SpringBoot的生态太成熟了将来你想加Redis、加MQ、加文件存储都有非常标准的整合姿势不会把自己锁死。Vue能成为前端主流不是没有原因的。管理后台这种场景本质上是大量的列表、表单、弹窗、表格Vue的组件化开发特别契合这种“重复但不完全相同”的页面模式。你把一个老人档案表格封装成组件换床位管理页面的时候改改列名和数据源就能复用开发效率肉眼可见地提升。MyBatis对比JPA最大的优势是SQL完全可控。敬老院管理系统里有大量多表关联查询和统计报表比如“查询某栋楼当前入住老人及护理等级”这种SQL用MyBatis写起来很直接出了问题也好排查。至于MySQL轻量、稳定、部署简单中小型系统首选。选这套组合还有一个实际考量招聘市场上SpringBoot和Vue的岗位需求量本身就大简历上写这个项目面试官问起来你也讲得出东西。技术栈不在新在于你能把它讲透。2. 系统功能模块与数据库设计2.1 功能模块怎么拆一个管理后台的前端菜单结构基本就等于系统的功能地图。我建议按下面这个粒度拆分既不会太碎也不会太粗。系统管理用户管理、角色管理、菜单管理。这个是权限体系的承载通常只有管理员能看到。老人管理老人档案、入院登记、转床与出院办理。老人的状态流转都在这里处理。床位管理楼栋、房间、床位三级结构床位状态空闲/入住/维修可视化展示。护理管理排班表、护理记录、交接班记录。按日期和护理员两个维度查询。费用管理账单生成、缴费记录、押金管理。支持按老人查询缴费历史。访客管理访客预约、登记记录、黑名单。对接老人家属来访场景。每个模块内部再拆细就是后端接口的清单。我习惯的做法是先画菜单树再根据菜单树列出每个页面的字段和操作按钮最后据此设计接口和表结构。菜单定下来前后端的分工边界也就清晰了。2.2 表结构设计的关键点表结构是这类项目最容易返工的部分。我梳理一下核心表你可以直接参考这个思路去建。sys_user用户表字段包括id、username、password、real_name、phone、status、create_time。密码字段存的是BCrypt加密后的密文长度要留足60位以上。sys_role角色表id、role_name、role_key、remark。sys_user_role用户角色关联表就是两个ID。elder_info老人档案表这是全系统最核心的表。字段包括elder_name、gender、birth_date、id_card、phone、health_status、care_level、entry_date、status。care_level对应护理等级自理/半自理/全护理status表示在院/出院。room_info房间表building_no、room_no、room_type、floor。bed_info床位表room_id、bed_no、status。nursing_record护理记录表elder_id、nurse_id、record_content、record_time。fee_order费用订单表elder_id、fee_type、amount、pay_status、pay_time、remark。这里我想特别强调一个点外键尽量少用物理外键而是在代码层面维护逻辑关联。之前接过一个老项目外键约束一塌糊涂往子表插数据的时候频繁报外键错误排查半天结果是数据清理不干净。用逻辑外键配合索引既灵活又好维护。另外金额字段千万不要用Float精度会出问题用Decimal(10,2)是标准做法。2.3 权限模型怎么设计权限这块直接上RBAC基于角色的访问控制模型也就是用户-角色-菜单三级关系。前端根据角色动态生成菜单后端在接口层面做鉴权两者缺一不可。前端动态菜单解决的是“用户能看见什么”后端接口鉴权解决的是“用户能调什么”。如果只做前端隐藏菜单别人用Postman直接调接口一样能拿到数据所以后端校验是必须的。我在这个项目里的做法是登录时后端返回该用户的角色和菜单列表前端存到Vuex里并动态注册路由后端在拦截器里校验每个请求的URL是否匹配该用户可访问的权限标识。角色层面至少要区分三种管理员系统配置全部数据、护理员护理记录老人查看、前台入院登记访客费用。具体到接口粒度不需要细到按钮级别做到菜单和操作权限级别就足够支撑这个业务了。3. SpringBoot后端核心实现要点3.1 工程结构与统一返回结果后端工程结构我习惯按技术分层组织清晰且不容易乱。com.example.elderly ├── controller // 接口层只做参数接收和结果返回 ├── service // 业务逻辑层 ├── mapper // MyBatis数据访问层 ├── entity // 数据库实体 ├── config // 配置类拦截器、跨域、分页插件 └── common // 统一返回结果、异常处理、工具类Controller层要瘦Service层要壮。一个常见的坏习惯是把业务逻辑写在Controller里看起来代码少了实际上后续维护非常痛苦。我写过一堆Controller全是if/else的项目改一个需求要翻好几个文件。前后端分离项目必须约定统一的返回格式否则前端处理异常逻辑会变成灾难。我的Result类是这样设计的。public class ResultT { private Integer code; // 200成功400业务错误401未登录500系统异常 private String msg; private T data; public static T ResultT success(T data) { ResultT result new Result(); result.setCode(200); result.setMsg(操作成功); result.setData(data); return result; } public static T ResultT error(Integer code, String msg) { ResultT result new Result(); result.setCode(code); result.setMsg(msg); return result; } }配合全局异常处理器RestControllerAdvice后端任何异常都会转成统一的JSON结构返回。前端只需要关注code即可不用每个接口单独处理异常分支。3.2 MyBatis配置与关键SQL写法MyBatis的配置其实没多少神秘的地方核心是几个容易踩坑的点要提前处理。mybatis: mapper-locations: classpath:mapper/*.xml type-aliases-package: com.example.elderly.entity configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImplmap-underscore-to-camel-case这个配置强烈建议打开它可以自动把数据库的snake_case字段映射到实体的camelCase属性省去大量resultMap手写工作。log-impl配成StdOutImpl可以实现在控制台打印SQL开发阶段排查问题必备。分页插件用PageHelper这是MyBatis生态里最常用的分页方案。用法很简单但有一个铁律必须记住PageHelper.startPage(pageNum, pageSize)后面紧跟的必须是第一条SQL查询语句。PageHelper.startPage(pageNum, pageSize); ListElderInfo list elderInfoMapper.selectElderList(condition); PageInfoElderInfo pageInfo new PageInfo(list);返回值用PageInfo前端拿到的数据里会包含total、pages、pageNum、pageSize这几个字段配合Element-UI的el-pagination组件填进去就能用。最美妙的是PageHelper的物理分页会自动在SQL后面拼接LIMIT不会像逻辑分页那样把全表数据捞出来性能上完全不用操心。多条件查询的SQL我建议用动态SQL来写。比如老人档案列表支持按姓名、护理等级、状态筛选XML里的写法是这样。select idselectElderList parameterTypemap resultTypeElderInfo select * from elder_info where if testelderName ! null and elderName ! and elder_name like concat(%, #{elderName}, %) /if if testcareLevel ! null and careLevel ! and care_level #{careLevel} /if if teststatus ! null and status #{status} /if /where order by create_time desc /selectwhere标签的经典价值在于当所有条件都为空时它会自动去掉WHERE关键字当有条件时它会自动处理掉第一个多余的AND。这种细节普通文档很少讲清楚实际写起来能少很多if/else拼接SQL的麻烦。3.3 登录认证与接口权限控制登录认证这块我没用重型的Spring Security OAuth2方案而是选择了JWT 拦截器。不是说Security不好而是对于这种规模的项目Security的过滤器链配置成本偏大维护起来反而麻烦。JWT的思路直白也容易讲明白。登录流程是这样的用户提交用户名和密码后端校验通过后生成一个JWT token返回给前端前端把token存在localStorage里每次请求在Authorization头里带上。后端拦截器统一放行登录接口其余接口全部校验token合法性。Component public class JwtInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { // 放行预检请求 if (OPTIONS.equalsIgnoreCase(request.getMethod())) { return true; } String token request.getHeader(Authorization); if (token ! null JwtUtil.validateToken(token)) { // 解析出来的用户ID放入request方便后续使用 request.setAttribute(userId, JwtUtil.getUserId(token)); return true; } // 未登录返回401 response.setStatus(401); response.setContentType(application/json;charsetUTF-8); response.getWriter().write({\code\:401,\msg\:\未登录或登录已过期\}); return false; } }密码存储老生常谈但还是要强调一句绝对不要用MD5存密码。MD5撞库太容易了现在随便一个在线MD5查询网站都能反查常见弱密码。我用的是Spring Security里的BCryptPasswordEncoder每次加密结果带随机盐同一个密码两次加密出来的密文都不一样安全性靠谱。另外拦截器注册时要注意放行路径一般包括登录接口、静态资源路径、如果前后端分离部署了还要放行前端静态资源的请求。4. Vue前端核心实现要点4.1 工程初始化与Axios封装前端我用Vue CLI创建工程安装依赖时只装必要的vue-router、axios、element-ui、sass。如果你的Node版本比较新创建Vue2项目时会遇到一些兼容性问题建议直接锁Vue3 Vite的组合或者把Vue CLI版本固定好。Axios封装是整个前端工程里最关键的一个基础文件。我动手写所有业务页面之前先把request.js写好这样后面所有接口调用都走同一套逻辑。import axios from axios // 创建实例 const service axios.create({ baseURL: /api, timeout: 10000 }) // 请求拦截器统一携带token service.interceptors.request.use(config { const token localStorage.getItem(token) if (token) { config.headers[Authorization] token } return config }, error { return Promise.reject(error) }) // 响应拦截器统一处理code service.interceptors.response.use(response { const res response.data if (res.code 200) { return res } else if (res.code 401) { // token失效跳回登录页 localStorage.removeItem(token) window.location.href /login return Promise.reject(new Error(登录已过期)) } else { return Promise.reject(new Error(res.msg)) } }, error { return Promise.reject(error) }) export default servicebaseURL统一用/api这个前缀不是随便定的。开发环境靠Vue的devServer代理转发到后端生产环境靠Nginx反向代理转发前后端联调和部署时只要改代理配置前端代码完全不用动。4.2 路由与侧边栏菜单路由权限控制是管理后台的标配需求。我的做法分两步走第一步是设置全局前置守卫判断有没有token决定是否跳转登录页第二步是登录成功后根据后端返回的菜单数据动态添加路由。// 动态添加路由的关键函数 function addDynamicRoutes(menus) { // menus是后端返回的菜单列表递归处理成路由结构 const dynamicRoutes [] menus.forEach(menu { if (menu.component) { dynamicRoutes.push({ path: menu.path, name: menu.name, component: () import(/views/${menu.component}), meta: { title: menu.title, icon: menu.icon } }) } if (menu.children) { // 递归处理子菜单 } }) // 把动态路由注册到router实例上 dynamicRoutes.forEach(route { router.addRoute(route) }) // 最后添加404兜底路由 router.addRoute({ path: /:pathMatch(.*)*, redirect: /404 }) }动态路由有一个容易卡壳的坑import()的路径参数必须是静态字符串拼接不能用完全动态的变量否则webpack打包时无法解析模块依赖编译出来的代码会缺失对应JS文件。这个问题我当时排查了很久最终的解决办法是后端菜单里存的component字段命名必须和views目录下的文件名严格一致而且路径拼接要用相对路径字符串来处理。侧边栏菜单的渲染就很简单了Element-UI的el-submenu和el-menu-item递归渲染el-menu就行。菜单数据存到Vuex里刷新页面时再从后端拉一次这样不会因为刷新丢状态导致白屏。4.3 老人档案和床位管理页面的实现思路老人档案页面是整个系统里最典型的“列表弹窗”页面实现思路可以套用到其他模块上。页面上方是筛选条件区姓名、护理等级、状态中间是表格区底部是分页右侧是新增/编辑弹窗。表格列定义需要注意几个细节。操作列固定宽度并设置fixedright防止横向滚动时操作按钮看不见状态列用el-tag渲染不同颜色在院绿色、出院灰色日期列后端返回的是2024-06-15T10:30:00这种格式前端不能直接展示需要格式化成2024-06-15。el-table :datatableData v-loadingloading border stripe el-table-column propelderName label姓名 width120 / el-table-column propcareLevel label护理等级 width120 template slot-scopescope el-tag :typecareLevelTag(scope.row.careLevel){{ scope.row.careLevel }}/el-tag /template /el-table-column el-table-column propstatus label状态 width100 template slot-scopescope el-tag :typescope.row.status 1 ? success : info {{ scope.row.status 1 ? 在院 : 出院 }} /el-tag /template /el-table-column el-table-column label操作 width200 fixedright template slot-scopescope el-button sizemini clickhandleEdit(scope.row)编辑/el-button el-button sizemini typedanger clickhandleDelete(scope.row)删除/el-button /template /el-table-column /el-table床位管理页面的实现思路略有不同更适合用卡片视图而不是表格。我按楼栋分组每个房间渲染成一张卡片床位的每个铺位用色块表示状态绿色空闲、红色入住、灰色维修。点击空闲床位弹出分配老人的对话框。这种可视化展示在实际交付时很加分院方管理人员一看就明白不用反复解释。5. 前后端联调与部署实战5.1 开发环境联调配置开发阶段前后端是分开跑在两个端口上的前端默认8080端口Vue CLI后端默认8080端口SpringBoot。端口冲突怎么办最简单的方式是改后端的端口为8081或者改前端的devServer端口为8082。我习惯保持后端8081前端8080然后在前端vue.config.js里配置代理。module.exports { devServer: { port: 8080, proxy: { /api: { target: http://localhost:8081, changeOrigin: true, pathRewrite: { ^/api: } } } } }这里有一个重要细节pathRewrite里的^/api: 意思是请求URL里的/api前缀在后端转发时会被去掉。原因是后端接口路径本身没有/api这个统一前缀比如登录接口是/auth/login前端请求/api/auth/login代理转发后变成http://localhost:8081/auth/login这样才匹配得上。如果你在后端设计了统一的前缀比如server.servlet.context-path/api那pathRewrite就要写成^/api: /api或者干脆不配。这个逻辑一旦搞混就会陷入“前端404但后端接口明明是好的”的尴尬局面。5.2 后端打包与环境区分后端打包很简单mvn clean package就能生成可执行JAR包。但我建议在动手打包前先把配置文件的环境切换考虑好。开发环境和生产环境的数据库连接、日志级别、文件上传路径都不一样不能用一个application.yml走到底。比较轻量的做法是SpringBoot的原生多环境配置spring: profiles: active: spring.profiles.active然后在pom.xml里配置Maven的profile打包时通过-Pprod参数指定环境。这样执行mvn clean package -Pprod打出来的包里自动带上生产环境配置部署时不用手工改文件。这个细节很多教程不写但实际项目里非常重要。要是生产环境数据库连接串配错了部署完才发现来回折腾的时间足够你后悔当初没做多环境配置。配置多环境还有一个隐性好处开发环境的SQL日志打开方便排查问题生产环境关闭SQL日志避免敏感信息泄露和性能损耗。5.3 生产部署的两种方案对比前后端分离项目部署我见过不少人直接把前端打包后的dist目录扔到后端SpringBoot的static目录里让后端一并启动。这种做法的确能跑但完全失去了前后端分离的意义。后端一改代码前端页面也要跟着重新打包发布根本没分开。我推荐的标准部署方案是前端用Nginx托管静态资源后端JAR包独立运行Nginx将/api开头的请求反向代理到后端服务。两者的生命周期完全独立前端更新时不用重启后端后端升级时前端无感知。Nginx核心配置如下你可以直接把server部分抄过去改改路径和域名。server { listen 80; server_name your-domain.com; # 前端静态资源 location / { root /opt/frontend/dist; index index.html; try_files $uri $uri/ /index.html; } # 后端接口反向代理 location /api/ { proxy_pass http://127.0.0.1:8081/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } }try_files $uri $uri/ /index.html;这一行是Vue在history路由模式下部署最关键的配置。如果漏了这行刷新页面比如访问/elder/list的时候Nginx会去找对应的物理文件找不到就返回404。很多人部署完发现“首页能打开一刷新就白屏”十有八九就是这个原因。后端的JAR包运行我推荐用systemd服务托管而不是简单的nohup java -jar。用systemd的好处是开机自启、崩溃自动重启、日志统一管理。写一个/etc/systemd/system/elderly.service文件里面配置启动命令和JAR包路径然后systemctl enable elderly就能设置开机自启。6. 常见问题与排查技巧实录6.1 跨域问题的三种情况前后端分离项目里跨域是绕不开的话题。我归纳下来实际操作中最容易遇到的跨域相关问题有三个。第一种开发环境请求正常但浏览器控制台一堆CORS报错。这时候先看代理配置有没有写到vue.config.js的devServer里以及有没有重新启动前端服务。改代理配置后必须重启前端热更新不会自动应用代理变化。第二种本地联调没问题Nginx部署后就报跨域了。这种情况通常是Nginx的代理头没有设置正确导致后端在解析请求来源时出现问题。按上面Nginx配置把proxy_set_header Host $host;加上基本能解决。第三种开发时直接在前端代码里写死了后端地址比如http://localhost:8081/auth/login这种。这种做法非常不规范部署到服务器后要么改代码重新打包要么继续跨域。一律要用相对路径/api前缀让代理去转发。6.2 数据库连接与SQL相关的坑数据库这块的坑绝大多数集中在时区、编码和SQL性能上。时区问题JDBC连接串不写serverTimezone参数高版本MySQL会直接拒绝连接。就算连上了时间字段也可能差8个小时。建议连接串里明确写serverTimezoneAsia/Shanghai或者干脆在MySQL的配置里把default-time-zone设置正确。中文乱码这个分两层看。第一层是数据库连接串要加characterEncodingutf8第二层是数据库表本身的字符集建表时统一用utf8mb4。utf8mb4兼容emoji也兼容绝大多数生僻字老人姓名里带生僻字很常见用utf8mb4最保险。SQL性能问题多表关联查询时一定给关联字段加索引。我在做访客记录查询的时候发现按老人ID和时间范围查询越来越慢查了下是elder_id字段没建索引。建上索引后从几秒降到了几十毫秒。分页插件PageHelper在多表分页时统计SQL偶尔会执行很慢可以在分页查询的SQL里尽量简化统计逻辑。6.3 前端部署的经典白屏问题自己部署一次前端踩的坑才算真的学会。我总结一下小白最容易遇到的白屏类问题。第一个是刷新404上面说过了配置try_files解决。第二个是静态资源加载404。这种情况通常是把页面打开后发现JS、CSS全部加载失败。排查思路很简单按F12看网络请求找到404的资源路径跟项目配置的publicPath对比。如果资源路径是/static/js/xxx.js而前端文件实际存放在/opt/frontend/dist/static/js/xxx.js那就是Nginx的root配置不对Nginx配置里root应该指向dist所在的目录。第三个是接口全部走404。检查前端请求的实际URL和后端接口是否匹配重点看Nginx的location /api/块。还有一个隐蔽问题如果Nginx的proxy_pass路径写的是http://127.0.0.1:8081而不是http://127.0.0.1:8081/带不带末尾斜杠含义完全不同。带斜杠时原始请求的/api前缀会被替换掉不带则原样转发。搞不清的可以先在Postman里直接调后端接口确认后端没问题再回头查Nginx配置。部署完成之后的一些体会说实话这类管理系统本身并不炫酷但正因为它贴近真实业务反而把前后端分离开发里那些最实际的问题都暴露了一遍。我做完这个项目最深的感触是技术栈只是工具真正决定系统好不好用的是数据结构设计得清不清楚、接口约定得规不规范、部署方案考虑得周不周全。如果你正在拿这个项目练手我建议你重点体会三个环节一是把老人在院、出院、转床的状态流转想清楚这个业务逻辑理顺了后面开发会非常顺二是把接口的统一返回格式和前端拦截器做扎实这能省掉后续联调时大量重复沟通三是把Nginx部署那套配置亲手敲一遍部署一次比看十篇教程都管用。踩过坑之后这些东西才是真的属于你自己的。