
前阵子帮朋友评估一个疗养院管理系统的源码技术栈是SpringBoot2Vue3MyBatis-PlusMySQL8.0前后端分离还带了配套开发文档。我原本以为这类“Java Web后台管理系统”都长得差不多但真正把业务模块、表结构、前后端联调和部署细节过了一遍之后发现里面值得展开讲的东西远比想象中多。这篇文章就按我实际梳理这套系统的顺序来写从业务建模讲到后端工程从MySQL 8.0接入讲到Vue3前台落地最后再聊部署环节最容易翻车的地方希望给正在做类似管理系统的朋友一点参考。如果你手头正在学Spring Boot和Vue3想找一个能覆盖“完整业务闭环”的练手项目或者团队要快速交付一套内部管理系统想搞清楚这类项目从零到上线的关键环节这篇文章应该对你有帮助。内容不追求炫技只讲正常团队做真实项目时会遇到的事。1. 先别急着敲代码疗养院管理系统在业务上究竟要管什么很多初学者拿到这类项目源码第一反应是打开数据库看表或者直接跑前端页面。我的习惯相反先站在业务方视角把“这个系统是谁在用、每天要处理什么事”捋清楚。疗养管理系统表面上是增删改查实际上业务对象之间是有主从关系和状态流转的搞不清这些后面写接口和设计表结构一定会返工。1.1 核心业务对象拆解老人、床位、护工、家属疗养院管理系统最核心的主体是“入住老人”围绕老人这个主体会延伸出几个强关联对象。第一是老人档案。除了姓名、性别、身份证号、联系方式这类基础信息还应该包含家属或紧急联系人信息、既往病史、过敏药物、入住日期、生活自理能力评估等级。这些信息决定了老人适合住在哪个区域、需要什么等级的护理服务也是后面生成健康档案和护理计划的数据基础。第二是床位资源。疗养院通常按楼栋、楼层、房间、床位四个层级管理物理空间每一张床位都会关联一个状态空闲、已入住、维修、预留。把床位建模成一张独立的表而不是把房间号直接写在老人档案里好处是能追溯每张床的历史入住记录也能在办入住时快速筛选可用床位。第三是护工与护士。护工需要排班护士需要记录老人的护理操作、用药情况、生命体征所以系统里要有员工账号、角色权限以及排班记录表。这里有一点容易被忽略护工和护士的操作记录是需要留痕的比如哪天哪位护工给哪位老人做了清洁护理这些流水数据既是服务质量考核的依据也是纠纷回溯的凭证。第四是家属。家属不是一个活跃的操作角色更多是“查询者”查看老人健康动态、费用账单、探视预约记录。所以设计权限时家属账号通常只开放只读接口和预约类接口不能操作核心业务数据。1.2 关键业务流程入院、护理、用药、出院结算理清对象之后要把业务流程串起来。疗养院最典型的一条主流程是老人入院时先登记档案、评估自理能力、分配床位、签署入住协议系统生成一条“在住记录”在住期间护工每天填写护理记录单护士记录用药情况和健康指标医生可以更新诊疗建议月末或退住时系统根据床位费、护理等级、餐饮费、药品费等项目生成费用账单办理结算后释放床位在住记录归档。这条主流程决定了系统的表结构不是一堆彼此独立的单表而是围绕“老人主档”衍生出的业务流水。比如老人信息表只是档案真正体现业务状态的是“入住记录表”这张表里有老人ID、床位ID、入住时间、预计离院时间、实际离院时间、状态等字段。所有护理记录、用药记录、费用流水都应该挂在入住记录上而不是直接挂在老人表上这样历史数据才能隔离清楚同一个老人二次入住时也不会跟第一次的数据混在一起。1.3 表结构设计思路主档表加业务流水表这套系统的表设计逻辑可以概括为“一主多从状态独立”。老人信息表是主档它只保存相对静态的信息健康档案表、护理记录表、用药记录表、费用流水表都是围绕某一段入住记录展开的流水。建表时有几个经验值得记住。第一状态字段不要用没有业务含义的魔法值比如0、1、2到底代表什么最好在代码里用常量类或枚举统一管理。第二金额字段别用double用decimal涉及费用计算时精度问题会少很多。第三凡是需要追溯历史的关联字段比如床位ID、护理等级在流水表里要保留操作发生时的快照值而不是简单地关联一个外键。因为护理等级三个月后可能调价如果流水表只存等级ID不存当时的价格快照后面算历史账单会非常痛苦。2. 后端工程是怎么搭起来的Spring Boot 2 目录规范与 MyBatis-Plus 的完整接入从这一节开始进入正题。这套系统后端基于Spring Boot 2.xORM层选的是MyBatis-Plus。我看了不少类似项目的源码目录结构和配置五花八门其实有一套相对标准的组织方式能让团队协作时的沟通成本低很多。2.1 后端分层目录不是随便建包而是按职责隔离我推荐的目录结构大概是这样的com.example.sanatorium ├── config // 配置类MyBatis-Plus分页、跨域、拦截器、自动填充 ├── controller // 接口层接收请求做参数校验返回统一结果 ├── service // 业务层接口定义 impl实现 ├── mapper // MyBatis-Plus的Mapper接口 ├── entity // 数据库实体和表结构一一对应 ├── dto // 前端交互对象请求参数、返回VO ├── common // 通用类统一返回结果、异常处理、常量、枚举 └── util // 工具类很多项目会把所有代码堆在controller里或者让entity直接返回给前端。短平快的demo可以这么干但真正要维护的系统不行。这里的原则是controller只做参数接收和结果转换业务判断放在service层数据库操作在mapper层entity不直接暴露给前端避免表结构改动影响接口返回。这套系统的接口返回结构也做了统一大概长这样public class ResultT { private Integer code; private String message; private T data; // 省略getter/setter }所有接口都返回Result对象前端统一处理code字段登录失效、参数错误、业务异常的语义就都不需要各自定义了。2.2 MyBatis-Plus 比 MyBatis 多出来的“省事点”如果只用原生MyBatis每个实体类对应的单表CRUD都要写SQL和XML映射纯属重复劳动。MyBatis-Plus的核心价值是继承了BaseMapper之后简单的单表操作用内置方法就能完成不用写SQL。public interface ElderMapper extends BaseMapperElder { }这样一张表的selectById、insert、updateById、deleteById就全都有了。业务层再继承ServiceImpl连service的基本实现都省了大半public interface ElderService extends IServiceElder { } Service public class ElderServiceImpl extends ServiceImplElderMapper, Elder implements ElderService { }有人担心这种“免写SQL”会让SQL不可控其实不是。MyBatis-Plus只是替你处理了90%的单表单主键操作复杂的多表关联查询、需要优化执行计划的SQL你照样可以写在Mapper接口里用Select或者XML实现两者并不冲突。我在这个项目里常用的组合是单表操作用MP内置方法多表统计和报表类查询走自定义SQL两边都不耽误。2.3 分页插件、自动填充、逻辑删除、乐观锁这四件套MyBatis-Plus要正常工作有几个配置必须做对。第一是分页插件。MP的分页需要先注入拦截器不然调用page方法时不会真正执行分页SQL。Configuration public class MybatisPlusConfig { Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor new MybatisPlusInterceptor(); interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); return interceptor; } }加上这个之后分页就变成了PageElder page elderService.page(new Page(pageNum, pageSize), new LambdaQueryWrapperElder().eq(Elder::getGender, 1));第二是自动填充。createTime、updateTime这种字段如果每个插入和更新都要手动set很容易漏。用MetaObjectHandler统一处理。Component public class MyMetaObjectHandler implements MetaObjectHandler { Override public void insertFill(MetaObject metaObject) { this.strictInsertFill(metaObject, createTime, LocalDateTime.class, LocalDateTime.now()); this.strictInsertFill(metaObject, updateTime, LocalDateTime.class, LocalDateTime.now()); } Override public void updateFill(MetaObject metaObject) { this.strictUpdateFill(metaObject, updateTime, LocalDateTime.class, LocalDateTime.now()); } }实体字段上再配合TableField(fill FieldFill.INSERT)之类的注解两个时间字段就再也不用手动处理了。第三是逻辑删除。老人档案、员工账号这类数据业务上不希望物理删除尤其是牵涉费用记录和护理历史的删除后审计就断了。所以在实体上标注TableLogicdeleteById方法就会自动变成update语句把deleted字段置为1查询时自动带上deleted0的条件。注意逻辑删除字段要建索引查询条件多的时候不然会拖慢性能。第四是乐观锁。分配床位、办理缴费这类并发操作容易发生两个人同时操作同一张床位的问题。Version注解加在版本号字段上更新时MP会自动在SQL里带上version条件更新失败让用户重试即可。这个对疗养院系统的意义在于前台办理入住时可能有多个窗口同时操作床位不能重复分配。2.4 查询条件构造器与服务层套路日常开发中条件查询不要再用Map传参再拼SQLMP的LambdaQueryWrapper可以让你写出编译期就能发现字段名拼写错误的代码。比如按老人姓名、入住状态、入院时间段筛选LambdaQueryWrapperElder wrapper new LambdaQueryWrapper(); wrapper.like(StringUtils.hasText(name), Elder::getName, name) .eq(status ! null, Elder::getStatus, status) .between(startTime ! null endTime ! null, Elder::getAdmissionTime, startTime, endTime) .orderByDesc(Elder::getCreateTime);条件为true时才会拼进wherenull条件直接跳过完全不用手动拼接SQL片段。批量更新床位状态也有LambdaUpdateWrapper可用LambdaUpdateWrapperBed updateWrapper new LambdaUpdateWrapper(); updateWrapper.eq(Bed::getElderId, elderId) .set(Bed::getStatus, BedStatusEnum.FREE.getCode()) .set(Bed::getElderId, null); bedService.update(updateWrapper);这套组合拳打下来单表操作基本不碰SQL代码量直线下降。真正需要写SQL的地方一类是报表统计比如按月统计入住率、护理服务量这类查询要用到group by和日期函数另一类是复杂多表分页查询比如前台列表需要同时展示老人姓名、床位号、护理等级、最近一次护理时间这时候直接用自定义SQL把多表关联处理好效率远高于在Java代码里循环查询。3. MySQL 8.0 接入的四个关键细节连不上、时区、驱动与字符集这套系统对应的数据库版本是MySQL 8.0。很多人在这个环节栽跟头不是因为SQL写不对而是驱动、连接串、数据库编码这些“边缘配置”没处理好导致项目连不上库或者连上之后出现各种奇怪现象。3.1 JDBC驱动与URL版本对应关系Spring Boot 2.x默认管理的MySQL驱动版本是8.0.x所以在pom里引入mysql-connector-j时直接用spring-boot-dependencies管理版本就行不需要指定version。dependency groupIdcom.mysql/groupId artifactIdmysql-connector-j/artifactId scoperuntime/scope /dependency连接串是另一个重灾区。下面这个是我实际使用的配置spring: datasource: url: jdbc:mysql://localhost:3306/sanatorium?useUnicodetruecharacterEncodingutf8zeroDateTimeBehaviorconvertToNulluseSSLfalseserverTimezoneAsia/ShanghaiallowPublicKeyRetrievaltrue username: root password: yourpassword driver-class-name: com.mysql.cj.jdbc.Driver逐个解释一下关键参数为什么必须带。useUnicode和characterEncoding是为了让中文正常读写配合数据库端utf8mb4编码使用。serverTimezoneAsia/Shanghai必须设置MySQL 8.0和JDBC驱动默认对时区的处理规则不一样不指定的话会抛异常CST时区无法识别或者返回的时间比实际时间早8小时。allowPublicKeyRetrievaltrue也是MySQL 8.0的专属坑因为8.0默认的密码加密插件是caching_sha2_password使用SSL非加密连接时驱动需要向服务端请求公钥必须显式允许否则会报“Public Key Retrieval is not allowed”。3.2 数据库字符集utf8mb4才是正解建库时如果只写utf8会遇到一个问题utf8在MySQL里实际是utf8mb3它最多只能存3字节的字符遇到emoji或者其他生僻汉字就会报“Incorrect string value”。现在很多手机号、姓名备注里混着表情符号所以建库建表统一用utf8mb4最稳妥。CREATE DATABASE sanatorium DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;排序规则里utf8mb4_general_ci和utf8mb4_unicode_ci的差别在中英文混合排序时略有体现但普通管理系统差别不大选general_ci性能稍好一些。3.3 本机安装和Docker部署两条路线Windows本机开发时直接从官网下载MySQL Installer选Server Only就可以。安装过程中会要求设置root密码开发环境可以设成简单密码生产环境务必用强密码。装完后在系统服务里能看到MySQL服务默认开机自启。有个小技巧安装时如果用自定义目录记得把MySQL的bin目录加到系统PATH里这样命令行才能直接敲mysql命令。如果不想污染本机环境用Docker跑MySQL 8.0其实更干净团队协作时也能保证版本完全一致。下面是我常用的启动命令docker run -d \ --name mysql8 \ -p 3306:3306 \ -e MYSQL_ROOT_PASSWORDroot123 \ -e MYSQL_DATABASEsanatorium \ --restartalways \ -v /opt/mysql-data:/var/lib/mysql \ mysql:8.0注意数据卷一定要挂载出来不然容器删掉后数据全没了。另外用docker部署时如果你的后端服务也跑在容器里连接地址要写MySQL容器的服务名或IP而不是localhost这是新手最容易绕不过去的点。3.4 连接失败排查清单实际开发中连接MySQL报错的原因可以列一个排查清单Access denied for user账号密码错误或者该账号不允许从当前主机连接需要执行GRANT授权。Public Key Retrieval is not allowed连接串缺少allowPublicKeyRetrievaltrue加回去。Communications link failure网络不通、MySQL端口没监听、防火墙拦截先telnet测端口。Unknown database数据库名写错了或者没有提前创建数据库。The server time zone value CST is unrecognized连接串缺少serverTimezone参数。这些错误基本都是配置层面的不是代码问题排查顺序从连接串、账号权限、网络联通性、数据库实例状态依次确认通常半小时内能定位。4. Vue 3 前端实战从项目初始化到权限路由和后台组件封装后端接口ready之后前端的工作一点不比后端少。这套系统前端用的是Vue 3开发体验比Vue 2时代提升不少但有几个地方如果你没适应Composition API的思维方式写着写着又会绕回Vue 2的老路。4.1 Vite创建Vue3项目与目录规划创建项目的命令现在基本都是Vite了npm create vitelatest sanatorium-web -- --template vue cd sanatorium-web npm install相比Vue 2时代依赖vue-cli和webpackVite的开发服务器启动速度是秒级的HMR热更新也快得多这个体验升级用一次就回不去。安装完成后我会额外装vue-router、pinia、axios和element-plus。管理系统的目录布局建议如下src ├── api // 所有后端接口定义按模块拆分 ├── assets // 静态资源 ├── components // 公共组件 ├── layout // 后台布局框架 ├── router // 路由配置 ├── store // pinia状态管理 ├── utils // 请求封装、工具函数 └── views // 页面按业务模块划分api目录单独拎出来是一种好习惯。每个模块的接口都集中放在一个文件里比如elder.js就放所有老人相关的接口调用页面组件只负责调用api函数不直接写axios请求这样接口地址改动时只动一个文件。4.2 请求封装与登录状态处理管理系统的请求层需要统一做三件事带token、统一处理错误码、统一处理登录失效。axios实例大概是这样import axios from axios import { ElMessage } from element-plus import { useUserStore } from ../store/user import router from ../router const request axios.create({ baseURL: /api, timeout: 10000 }) request.interceptors.request.use(config { const userStore useUserStore() if (userStore.token) { config.headers.Authorization Bearer ${userStore.token} } return config }) request.interceptors.response.use( response { const res response.data if (res.code ! 200) { ElMessage.error(res.message || 请求失败) return Promise.reject(new Error(res.message)) } return res }, error { if (error.response error.response.status 401) { const userStore useUserStore() userStore.clearToken() router.push(/login) } ElMessage.error(error.message || 网络异常) return Promise.reject(error) } ) export default request这里有个细节开发环境通过Vite代理把/api转发到后端生产环境由Nginx把/api转发到后端所以前端代码里baseURL统一写成/api就行不用关心后端具体部署在哪台机器。4.3 动态路由与菜单权限的实现思路管理系统里不同角色的权限差异很大。管理员能看到系统管理、员工排班、费用结算模块护士只能看到护理记录、健康档案家属账号只能看到老人动态和账单。这些权限在路由层面就要控制不能只靠隐藏菜单。实现思路是用户登录后后端返回当前用户有权限访问的菜单列表和路由标识前端根据这个列表动态生成路由。Vue 3中路由实例添加页面路由的方法如下const newRoute { path: /elder, name: ElderList, component: () import(../views/elder/ElderList.vue), meta: { title: 老人管理, icon: User } } router.addRoute(Layout, newRoute)菜单栏可以根据同一份权限数据递归生成保证菜单和路由永远一致。按钮级权限怎么做可以用自定义指令比如v-permissionelder:add指令内部检查用户权限列表没有权限就从DOM上移除按钮。这一步加上之后权限控制才算比较完整。对比Vue 2的开发体验Vue 3的Composition API在权限这种逻辑复用场景里优势非常明显。把“获取权限数据、生成路由、生成菜单”抽成usePermission这个组合式函数逻辑可以独立复用不再需要mixins那种隐式依赖。父子组件的交互也从this.$emit变成了defineProps和defineEmitsIDE的类型提示和代码排查都友好很多。4.4 列表页、表单弹窗和Tabs标签页的封装经验后台管理系统里80%的页面是“搜索条件区 表格区 分页区”如果每个页面都单独写一套工作量会非常大。实际项目里通常会封装一个PageList组件来屏蔽这些重复逻辑。表格列配置由页面传入搜索表单模型由页面定义封装组件负责处理加载、分页、刷新。但封装不是越抽象越好过度封装会导致页面想做一点定制很难下手。我的建议是先写两三个真实页面发现重复代码足够多在再抽组件不要一开始就设计万能组件。表单弹窗里的父子组件通信也是后台开发的高频场景。父组件打开弹窗传入要编辑的行数据子组件负责渲染表单并提交。在Vue 3中这样写父组件el-dialog v-modeldialogVisible title编辑老人信息 ElderForm v-ifdialogVisible :form-datacurrentRow submithandleSubmit / /el-dialog子组件里接收属性并触发提交事件script setup const props defineProps({ formData: { type: Object, default: () ({}) } }) const emit defineEmits([submit]) const handleSave () { // 表单校验通过后 emit(submit, localForm) } /script如果表单里还嵌了Tabs标签页比如把基本信息、健康档案、护理记录分别放在不同标签页里需要注意组件的缓存和初始化时机。数据回填时如果子组件是v-if控制的弹窗每次打开都会重新渲染直接把props赋值给子组件内部变量就行如果是keep-alive缓存过的页面就要在onActivated里重新拉取数据不能只依赖mounted。5. 联调、打包与部署跨域、Nginx和上线后容易忽略的问题开发环境下前后端联调通常没什么大问题真正的坑集中在打包部署环节。这一步我看到太多项目翻车明明本地跑得好好部署到服务器就白屏、接口404、时间不对。5.1 跨域问题开发代理与生产Nginx开发环境的跨域用Vite代理解决在vite.config.js里配置export default defineConfig({ server: { port: 5173, proxy: { /api: { target: http://localhost:8080, changeOrigin: true, // 如果后端接口没有/api前缀需要重写 rewrite: path path.replace(/^\/api/, ) } } } })生产环境的跨域不应该依赖前端代理。通常有两种做法一是把前端build后的dist目录直接放进Spring Boot的static目录和后端打成同一个jar这样根本不存在跨域问题二是前端静态资源交给Nginx接口通过反向代理转发到后端服务。第二种做法更灵活也是团队协作中更常见的部署方式对应的Nginx配置片段如下server { listen 80; server_name your-domain.com; root /opt/sanatorium-web/dist; index index.html; location / { try_files $uri $uri/ /index.html; } location /api/ { proxy_pass http://127.0.0.1:8080; 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那行是必须的。Vue Router如果开了history模式用户刷新某个子页面时Nginx需要先检查文件是否存在不存在就回退到index.html否则刷新后就是404。5.2 Spring Boot 打包与配置管理后端打包用Maven插件即可mvn clean package -DskipTests生成的可执行jar用java -jar直接跑java -jar sanatorium-backend.jar --spring.profiles.activeprod环境相关的配置要拆开application-dev.yml和application-prod.yml分别维护数据库地址、密码、日志级别都放在对应profile里不要把生产数据库密码写死在默认的application.yml中。启动脚本里可以通过环境变量注入配置值比如java -jar sanatorium-backend.jar \ --spring.datasource.password${DB_PASSWORD} \ --server.port8080使用环境变量的管理方式比直接改配置文件更安全也方便在不同环境之间切换。5.3 上线后容易忽略的几个配置项目上线后有几个配置如果不提前处理会在某个半夜突然报警。eer数据连接池参数要合理配置。Spring Boot默认使用HikariCP开发环境默认值够用但生产环境建议显式设置maximum-pool-size根据并发量一般设置在10到20之间。连接池太小会导致高峰期拿不到连接连接池太大会让MySQL因为连接数过多而负载升高。spring: datasource: hikari: maximum-pool-size: 20 minimum-idle: 5 connection-timeout: 30000另一个是Spring Boot Actuator的端点安全。如果引入了actuator依赖默认会暴露很多内部信息。生产环境建议只暴露health和info端点management: endpoints: web: exposure: include: health,info这样既能用健康检查探活又不会把beans、env、heapdump这些敏感信息暴露到公网。还有MySQL连接的空闲超时问题。MySQL 8.0默认的wait_timeout是8小时如果连接池里的连接超过这个时间没有活动会被服务端主动断开。连接池配置里可以设置max-lifetime略小于数据库的wait_timeout比如设为30分钟这样池里的连接会在被服务端断开前主动重建避免凌晨第一次请求时报“Connection has been closed”。6. 个人实操体会与后续可以扩展的方向把整个项目从前到后过完一遍我最大的感受是这类管理系统的开发瓶颈从来不是某个框架有多难而是你能不能把业务对象和状态流转梳理清楚。技术栈只是工具Spring Boot、Vue3、MyBatis-Plus、MySQL 8.0这套组合已经非常成熟真正拉开差距的是表结构设计是否合理、权限模型是否完整、部署配置是否经得起线上考验。回到这套疗养院管理系统本身它目前的覆盖范围已经足够支撑一家中小型疗养院的日常运营但要说扩展空间其实还有不少。比如给家属做一个微信小程序端查看老人健康数据和探视预约接收智能手环的体征数据对接健康监测大屏用药提醒功能可以对接短信或微信通知这部分我已经在个人项目里验证过用Spring Boot结合消息推送服务就能实现。如果你正准备拿这个项目学习或二次开发建议先跑通老人入住到出院结算的这条主链路再去补权限和报表这些外围功能你会发现整个系统越来越清晰。