ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

SpringBoot+Vue3+MyBatis+MySQL养老智慧平台实战:从设计到部署全解析

SpringBoot+Vue3+MyBatis+MySQL养老智慧平台实战:从设计到部署全解析 养老信息化这两年是个热门方向Java SpringBootVue3MyBatisMySQL 这套组合在管理系统领域也几乎成了默认配置但真正把技术栈落到“养老智慧服务平台”这种带完整业务场景的项目里从数据库设计到前后端联调再到上线部署坑比想象中多。这篇博客我打算用自己实际搭建这套系统的路径作为主线把业务模块拆解、后端分层、MyBatis的SQL和缓存细节、Vue3前端工程组织、MySQL表设计与优化以及部署阶段真正会遇到的问题一次聊明白。不论你是准备拿真实项目练手的Java开发、正在写养老方向毕业设计的学生还是打算在智慧康养领域积累经验的后端工程师这套实战路径都值得参考。1. 项目整体设计与思路拆解1.1 养老智慧服务平台的核心业务画像“养老智慧服务平台”听起来很宏大拆开看核心业务并不玄乎。平台至少需要服务三类人老人本人是服务对象但日常主动操作的往往是家属和护工护工、护士长负责具体照护任务比如送餐、打扫、健康测量、康复训练机构管理层则关心工单完成率、收费情况、服务满意度这些经营指标。围绕这三类角色基础模块可以归纳成老人档案管理、服务项目管理、工单派发与接单、健康数据记录、家属端消息通知、后台统计报表。这类模块有一个共同特点数据关系清晰、事务边界中等、逻辑以CRUD和状态流转为主正好匹配Java SpringBootMyBatisMySQL这套成熟方案的能力边界。业务复杂度和技术冗余之间需要平衡如果直接上微服务或者上时序数据库反而会把团队精力耗在基础设施上这是中小型养老平台最不划算的地方。所以我在架构上选择了最常见的单体SpringBoot服务配合前后端分离后续如果数据规模上来再逐步拆服务也来得及。具体到数据库层面老人、工单、服务项目、用户、通知这些实体之间有明确关联用关系型数据库建模非常自然。MySQL 8.0现在默认utf8mb4字符集对中文和特殊符号的支持都很好运维生态成熟遇到问题在社区里基本都能查到答案对新人的友好度也高。1.2 前后端分离架构为什么是必然选择养老平台的管理后台交互密度很高比如工单看板要实时刷新、健康数据要画趋势图、老人列表要做多条件筛选。这种场景如果用传统Thymeleaf服务端渲染每操作一次就整页刷新体验非常差而且后端每加一个页面都要跟着改模板和静态资源代码耦合严重。前后端分离后后端只产出REST接口Vue3前端独立打包成静态资源。这套结构带来的实际收益我用一个表格总结一下对比项服务端模板渲染前后端分离页面交互体验频繁整页刷新看板类功能不好做局部更新流畅图表和筛选交互顺手多端复用同一套模板很难给App和小程序用后端接口原样复用前端只需换客户端团队开发节奏前后端互相等待联调压力大前端用Mock数据并行开发联调集中进行上线更新改前端也得重新构建后端并重启前端替换静态文件即可后端独立发布取舍也是存在的跨域配置、Token过期、多环境接口地址管理都需要单独处理。但这些问题是工程化的正常成本相比服务端渲染对业务灵活性的限制前后端分离对这类平台来说收益远大于投入。后面第六部分我会专门讲联调和部署阶段怎么处理这些麻烦。1.3 技术栈选型的实际权衡选型上我用了SpringBoot 2.7.x MyBatis MySQL 8 Vue3 Vite每个环节都在社区成熟度和团队上手成本之间做了取舍。SpringBoot 2.7的资料最全网上问题都能搜到答案直接上SpringBoot 3.x也不是不行但javax到jakarta的命名空间迁移会把不少同事绕晕如果不是新项目开局没必要在启动阶段增加额外的沟通成本。MyBatis比JPA更适合这类偏报表统计的项目。养老业务里跨表关联、动态条件查询非常多SQL写在XML里完全可控索引怎么用、要不要joinDBA可以直接review而JPA的实体关系映射虽然常规CRUD快但碰到复杂统计查询时拼接Specification非常痛苦。Vue3我倾向用组合式API加script setup代码组织明显比Vue2的Options API清晰配合Pinia做全局状态管理比Vuex轻量不少。2. 后端工程搭建与核心业务实现2.1 Maven工程结构与包划分后端工程我习惯保持清晰的包边界起步时用单模块结构等业务模块真的多了再拆多模块也不晚。建议的包结构如下com.example.eldercare ├── controller # 接口层 ├── service │ └── impl # 业务逻辑层 ├── mapper # MyBatis的Mapper接口 ├── entity # 数据库实体 ├── dto # 接口入参出参对象 ├── config # 跨域、拦截器、分页插件等配置 ├── common │ ├── result # 统一返回封装 │ ├── exception # 自定义异常与全局异常处理 │ └── utils # 工具类分层原则很简单controller只做参数校验和调用service不在接口层拼SQLservice负责事务、业务规则和跨模块编排mapper只访问数据库。entity和DTO分开很重要数据库字段和前端需要的字段经常不一致比如列表页要展示“最近服务次数”这种统计字段强行塞进实体类会污染表结构映射DTO隔离一层能省掉很多后续麻烦。2.2 SpringBoot核心配置与撸依赖pom.xml里核心依赖无非这几个spring-boot-starter-web、mybatis-spring-boot-starter、mysql-connector-j、lombok、jjwt做Token鉴权、hutool工具类。这里有个选择题用MyBatis官方starter还是MyBatis-Plus我倾向于如果项目里复杂SQL占比高用官方MyBatis保持纯粹如果大量简单CRUD想省代码用MyBatis-Plus能提升不少开发效率。养老平台这种管理后台单表CRUD量很大实际用MyBatis-Plus会更舒服动态条件构造器配合分页插件都很成熟。下面是application.yml里非常容易踩坑的几个配置spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/eldercaredb?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/ShanghaiuseSSLfalse username: root password: yourpassword hikari: maximum-pool-size: 20 minimum-idle: 5 connection-timeout: 30000 mybatis: mapper-locations: classpath:mapper/*.xml type-aliases-package: com.example.eldercare.entity configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImpl server: port: 8080JDBC URL里的serverTimezoneAsia/Shanghai一定要加不加MySQL 8会报时区错误特别是服务器和本地开发机时区不一致的时候问题非常隐蔽。characterEncodingutf8配合表结构的utf8mb4能防止中文乱码。map-underscore-to-camel-case打开后数据库下划线字段名可以自动映射到驼峰属性少写很多resultMap。mapper-locations指向classpath:mapper/*.xml如果漏配置运行时会报“Invalid bound statement”之类的错误。2.3 统一响应、全局异常与JWT鉴权前后端分离项目里后端最好不要直接返回实体或者裸Map否则前端处理错误会很痛苦。我习惯固定一个统一返回对象RT字段就三个code、message、data。Data public class RT { private Integer code; private String message; private T data; public static T RT ok(T data) { RT r new R(); r.setCode(200); r.setMessage(success); r.setData(data); return r; } public static T RT fail(Integer code, String message) { RT r new R(); r.setCode(code); r.setMessage(message); return r; } }全局异常用RestControllerAdvice兜底。业务层发现参数不合法或者状态不对直接throw一个自定义BizException由异常处理器统一转成JSON响应。记得加一个ExceptionHandler(MethodArgumentNotValidException.class)来处理Valid参数校验失败的情况这样前端收到的永远是结构一致的JSON而不是莫名奇妙的报错堆栈。JWT鉴权这块我在拦截器里做两件事从请求头Authorization里取出Token解析出用户ID后放入ThreadLocal对登录接口、验证码等白名单路径直接放行。Token过期后由全局异常处理器返回401前端收到401就清理本地登录状态并跳回登录页。2.4 老人档案模块的Controller与Service示例拿老人档案列表页举例Controller很薄RestController RequestMapping(/api/elder) RequiredArgsConstructor public class ElderController { private final ElderService elderService; GetMapping(/page) public RPageResultElderVO page(ElderQuery query) { return R.ok(elderService.pageElder(query)); } PostMapping public RVoid save(RequestBody Valid ElderSaveDTO dto) { elderService.saveElder(dto); return R.ok(null); } }Service层负责组装分页条件和调用MapperService RequiredArgsConstructor public class ElderServiceImpl implements ElderService { private final ElderMapper elderMapper; Override public PageResultElderVO pageElder(ElderQuery query) { // 构造分页参数和筛选条件 PageElderVO page elderMapper.selectElderPage( Page.of(query.getPageNum(), query.getPageSize()), query); return PageResult.of(page.getTotal(), page.getRecords()); } }这个例子里分页参数和筛选条件都交给了Mapper的XML处理Service层没有SQL逻辑controller也看不到任何数据库细节。代码分层到这种程度后续加字段、加筛选条件都很容易定位改动范围。3. MyBatis 动态SQL、分页与缓存机制3.1 Mapper接口与XML动态SQL以前端经常传“姓名关键词”“照护等级”“社区ID”这种组合筛选条件为例XML里动态拼接WHERE是基本操作。一个典型的查询XML长这样select idselectElderPage resultTypecom.example.eldercare.entity.Elder SELECT id, name, gender, birthday, care_level, community_id, create_time FROM elder where if testname ! null and name ! AND name LIKE CONCAT(%, #{name}, %) /if if testcommunityId ! null AND community_id #{communityId} /if if testcareLevel ! null AND care_level #{careLevel} /if /where ORDER BY create_time DESC /selectwhere标签会自动处理首条无条件时的多余AND这是手写字符串拼接最容易翻车的地方。LIKE CONCAT(%, #{name}, %)这种写法能避免SQL注入直接字符串拼接%${name}%看起来省事但在MyBatis里用${}拼用户输入等于把注入漏洞摆在桌面上这个习惯必须改掉。如果用了MyBatis-Plus相同逻辑可以写成LambdaQueryWrapperJava代码更紧凑。但复杂统计SQL我还建议写在XML里因为SQL本身就是要给DBA看的代码里套一层Java实现在排查问题时反而多一步脑筋。3.2 分页查询的两种常用方案列表页分页是后台系统的刚需。一条思路是自己手写LIMIT offset, size通过计算(pageNum-1)*pageSize得到offset另一条思路是引入分页插件我用的是MyBatis-Plus自带的分页插件PaginationInnerInterceptor配置一个Configuration类即可。分页插件原理并不复杂拦截器拦截Mapper方法执行前拼一个COUNT语句再拼上LIMIT参数。但这东西也有副作用它默认会拦截所有查询包括一些不需要分页的统计方法所以使用时要给查询条件明确传Pagination对象不要依赖全局默认值。开发时我习惯把打印SQL的日志打开确认插件生成的SQL符合预期不然哪天SQL写复杂了生成的COUNT语句可能因为DISTINCT或GROUP BY而统计错乱。真正到数据量大的时候分页插件用OFFSET深翻页会越来越慢比如翻到第1万页MySQL依然要扫掉前10万行再丢弃。对这类场景优化策略通常是延迟关联或者游标分页这个放到第五部分数据库优化里详细讲。3.3 一级缓存、二级缓存的使用边界MyBatis缓存机制是面试常问的问题实际工程里也要心里有数。一级缓存默认开启作用范围是同一个SqlSession。在Spring整合环境下一次数据库事务通常对应一个SqlSession所以同一请求里连续两次相同查询会命中缓存数据库压力确实小一点。但要注意的是如果你在同一个事务里先查询某条记录然后通过另一个查询或者更新操作让数据发生变化再执行相同查询可能出现拿不到最新值的情况。解决方式很简单别把一次请求内的查询依赖当作全局缓存业务需要实时数据就别纠结这几个毫秒。二级缓存按Mapper namespace隔离作用范围扩展到了不同SqlSession。但它有个著名的坑多表查询时缓存一致性不好控制。比如工单列表查询join了老人表如果只有老人表被更新而工单namespace的缓存没有被清理查询结果就一直停留在旧状态。对养老平台这种对数据一致性要求高的业务我不建议随意开二级缓存真要提升查询性能优先用Redis做业务缓存命中逻辑由代码明确控制反而好排查问题。4. Vue3前端工程组织与核心页面4.1 Vite工程搭建与目录规范Vue3项目我默认用Vite起步创建前端工程非常快npm create vitelatest eldercare-web -- --template vue cd eldercare-web npm install后台管理系统页面多、交互密集目录结构建议按功能域划分而不是把所有组件都塞进一个views文件夹里。我实际用的结构是src/ ├── api/ # 按模块拆分的接口调用 ├── components/ # 可复用UI组件 ├── router/ # 路由表与导航守卫 ├── stores/ # Pinia状态模块 ├── utils/ # request封装、日期格式化等公共函数 └── views/ # 页面级组件前端项目最容易烂的就是api目录建议一个业务模块一个api文件文件名和接口路径尽可能保持一眼能对应。比如老人档案相关接口都放在api/elder.js里新增接口时按模块查找和修改都很方便不会出现一个全局api.js几千行崩溃的情况。4.2 Axios封装、Pinia状态管理与路由守卫所有接口请求我统一封装成一个request实例baseURL从环境变量里取开发环境指向Vite转发地址生产环境指向Nginx转发地址。请求拦截器自动带上Token响应拦截器统一判断业务状态码并处理异常。import axios from axios import { ElMessage } from element-plus import router from /router const request axios.create({ baseURL: import.meta.env.VITE_API_BASE_URL, timeout: 15000 }) request.interceptors.request.use(config { const token localStorage.getItem(token) if (token) { config.headers.Authorization Bearer ${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.data }, error { if (error.response error.response.status 401) { localStorage.removeItem(token) router.push(/login) } ElMessage.error(网络请求异常请稍后再试) return Promise.reject(error) } )Pinia用来管理用户信息、菜单权限和全局筛选状态。养老平台登录后返回用户角色前端根据角色生成可访问菜单动态注册路由导航守卫里没有Token就往登录页跳。这里有个细节路由守卫里使用Pinia store要在Pinia实例初始化之后否则会报getActivePinia相关的错误常见做法是store放在守卫函数内部通过useUserStore()获取而不是模块顶层。4.3 老人档案页面与组合式函数复用老人列表页用Element Plus做表格和筛选表单卡片式信息展示配一个弹出详情抽屉。页面核心逻辑用script setup组织把请求动作和翻页状态封装成可复用的组合式函数例如useElderList()返回列表数据、加载方法、分页参数这样就算多个页面都要用老人数据复用起来也很干净。script setup import { ref, onMounted } from vue import { getElderPage } from /api/elder const list ref([]) const total ref(0) const query ref({ pageNum: 1, pageSize: 20, name: , careLevel: }) async function loadData() { const data await getElderPage({ ...query.value }) list.value data.records total.value data.total } function handleSearch() { query.value.pageNum 1 loadData() } onMounted(loadData) /script组合式API的好处是相关逻辑可以放到一起不像Options API会把数据放data、方法放methods一个页面的逻辑散落好几处。写养老平台这种页面很多的系统组合式函数弹性和复用性都要好很多。5. MySQL表设计、索引与性能优化5.1 表设计规范与建表实例表设计是平台的根基。我习惯每个表包含主键、业务字段、审计字段三大块。主键用自增bigint简单好记业务字段按查询场景建索引审计字段统一为create_time和update_timeupdate_time用数据库的ON UPDATE CURRENT_TIMESTAMP自动维护代码不用手写更新时间省心很多。老人档案表可以这样建CREATE TABLE elder ( id BIGINT NOT NULL AUTO_INCREMENT COMMENT 主键, name VARCHAR(32) NOT NULL COMMENT 姓名, gender TINYINT NOT NULL DEFAULT 1 COMMENT 性别 1男 2女, birthday DATE COMMENT 出生日期, id_card VARCHAR(18) COMMENT 身份证号, care_level TINYINT NOT NULL DEFAULT 1 COMMENT 照护等级, community_id BIGINT COMMENT 所属社区, family_phone VARCHAR(20) COMMENT 家属联系电话, status TINYINT NOT NULL DEFAULT 1 COMMENT 状态 1正常 0停用, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_community (community_id), KEY idx_id_card (id_card) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT老人档案表;几个容易忽略的细节字符集必须用utf8mb4平台里可能存特殊符号和表情老版本utf8部分字符会报错身份证号虽然长得像数字但可能包含字母X而且长度18位用BIGINT会溢出类型上应该用VARCHAR(18)社区ID和身份证号是高频查询条件普通二级索引就够不要所有字段都加索引写入性能反而被拖垮。5.2 慢查询定位与索引优化数据量起来以后慢SQL是躲不开的问题。先打开MySQL慢查询日志slow_query_log 1 slow_query_log_file /var/log/mysql/slow.log long_query_time 1拿到慢SQL后用EXPLAIN看执行计划关键是看type列如果是ALL说明全表扫描优先考虑补索引如果是range或ref则说明索引发挥了一定作用。常见的坑是WHERE条件里对索引列做了函数运算比如WHERE YEAR(birthday) 2025即使birthday有索引也走不上改成birthday BETWEEN 2025-01-01 AND 2025-12-31性能立刻不一样。深分页也是管理后台的典型痛点。列表翻到几百页时直接LIMIT 100000, 20会让数据库扫描大量无用行我优化分页常用延迟关联SELECT e.* FROM elder e JOIN ( SELECT id FROM elder WHERE community_id ? ORDER BY id LIMIT 100000, 20 ) t ON e.id t.id这个写法先把主键ID限定到目标范围再回表查询完整记录避免了大范围的行复制体验提升非常明显。如果业务允许更推荐游标分页用WHERE id lastId ORDER BY id LIMIT 20每次翻页都基于上一页的最后ID数量大时稳定性最好。5.3 连接池与数据库运维细节SpringBoot默认的HikariCP连接池性能很好参数也不用调得太激进。唯一要留意的是连接池的max-lifetime必须小于MySQL的wait_timeout比如MySQL空闲超时默认8小时HikariCP的maxLifetime配置为1800000毫秒30分钟比较稳妥否则空闲连接被MySQL回收后再被应用使用会隔一段时间偶发“连接已关闭”的异常。连接池大小给20个对于这套平台通常够用但数据库服务端max_connections也要同步调大很多时候“Too many connections”不是连接池问题而是MySQL服务端默认的连接数限制太紧。如果以后用Docker部署MySQL还要注意容器内端口映射、数据目录挂载权限和MYSQL_ROOT_PASSWORD环境变量这三个地方很多人数据库容器起不来或者连不上问题都出在这几处。6. 前后端联调、部署打包与避坑手册6.1 开发环境接口转发与跨域处理开发阶段最磨人的就是跨域。我首选方案不是在后端写CORS而是在Vite的dev server里把/api前缀的请求转发到后端地址这样浏览器看到的请求都是同源的更贴近生产环境用Nginx转发的方式。// vite.config.js import { defineConfig } from vite import vue from vitejs/plugin-vue export default defineConfig({ plugins: [vue()], server: { host: 0.0.0.0, port: 5173, proxy: { /api: { target: http://localhost:8080, changeOrigin: true, rewrite: path path.replace(/^\/api/, ) } } } })这里rewrite的作用是去掉/api前缀因为后端接口路径本身不需要这个前缀。如果后端接口设计成/api/xxx开头那就别加rewrite保证前后端路径一致避免接口404又调半天。后端如果一定要加CORS支持用CorsFilter时要注意allowedOriginPatterns和allowCredentials配合使用不能简单发一个*通配符带Cookie和Token时浏览器会拒绝这种配置。实际项目里能走转发就别开CORS能省不少排查跨域的时间。6.2 Vue打包进SpringBoot的部署方式上线部署有两种主流方式。第一种是把前端打包后的dist目录复制到SpringBoot的src/main/resources/static目录让后端同时托管静态文件。这种方式只有一个Tomcat端口部署链路简单适合个人项目或小范围内部使用但缺点是前端每次更新都要重新打包后端工程并重启发布过程比较笨重。第二种是前后端彻底分离前端用Nginx托管静态文件后端单独起服务前端只需替换静态文件就能发布后端也可以独立扩展这套方案更适合长期运营的养老平台项目。Vue做的是SPA应用路由用history模式时Nginx配置必须加try_files规则否则用户直接在浏览器地址栏输入/elder/list这种子路径刷新Nginx会找不到对应文件而返回404location / { root /data/www/eldercare-web; index index.html; try_files $uri $uri/ /index.html; }API接口部分在同一份Nginx配置里把/api路径转发给后端接口服务即可。生产环境下只要保证静态文件目录路径正确、后端服务地址可达前后端就能各自独立更新互不影响。6.3 常见问题速查表实际开发过程中我整理了一份避坑清单这些坑基本覆盖了项目从开发到上线的常见故障场景现象原因与解决思路SpringBoot版本太高工程使用javax.*报编译错SpringBoot 3.x换成了jakarta包名老代码需要替换importMyBatis报Invalid bound statement接口方法调用时找不到SQL检查mapper-locations配置和XML位置是否匹配MySQL 8启动报时区异常数据源初始化失败JDBC连接串加serverTimezoneAsia/Shanghai中文乱码或保存出现问号接口返回和入库数据异常数据库表、连接串、页面编码统一为utf8mb4Vue打包后接口404页面打开但数据不加载检查环境变量VITE_API_BASE_URL是否与生产路径一致系统运行几小时后偶发连接报错长时间空闲后出现Connection异常连接池max-lifetime调小必须小于MySQL wait_timeoutDocker部署MySQL失败容器启动失败或客户端连不上核对端口映射、MYSQL_ROOT_PASSWORD和数据目录权限排查的思路一定要有顺序先看日志里的异常关键词再检查配置文件最后才怀疑代码逻辑大多数问题都出在环境和配置上不要一上来就重构代码。7. 项目跑起来之后的个人心得与扩展思路7.1 项目上线后的心得体会整套系统跑通之后我最大的体会是前后端分离项目真正的难点不在某个框架的API怎么调用而在于接口规范、数据规范和部署细节的配合。前期愿意花一小时把统一返回、统一异常、JWT鉴权这几个基础设施定下来后面写几十个接口都会事半功倍。如果一开始图快每个接口各写各的返回格式联调阶段改起来才是真正的地狱。另外业务平台的技术选型不需要追求前沿团队最顺手、社区资料最多的组合往往是推进速度最快的组合。这套SpringBootVue3MyBatisMySQL的方案可以被替换的环节都留了余地比如后续把MyBatis换成MyBatis-Plus、把Vue3组件库从Element Plus换成Ant Design Vue都是局部改动不会因为技术选型把整个项目锁死这种程度的技术债是完全可以接受的。7.2 后续扩展方向建议平台如果继续做深我会建议朝这几个方向走一是引入定时任务每天凌晨汇总老人健康数据和机构运营指标生成日报和月报二是健康监测设备的传感数据变成高频写入后先把上报接口接到消息队列削峰数据库只做持久化不要让物联网流量直接打穿MySQL三是热门报表查询结果用Redis缓存减少重复统计对数据库的压力四是数据量增长时对工单表和服务记录表做按时间归档核心表体积控制住查询性能才能长期保持。每次扩展都要守住一个前提旧接口兼容。前端无感、后端平滑升级这套前后端分离的结构给后续演进留了足够的空间。我实际用下来的感觉是养老智慧服务平台这种项目最适合用来把Java全栈链路跑通一遍业务不复杂但五脏俱全做完之后你对SpringBoot、Vue3、MyBatis、MySQL这四件套的理解会扎实很多。
返回列表