做这个项目的时候,我前后磨了大概三周时间。从空页面到跑通第一单“上门助浴”服务预约,中间踩得最多的不是技术难点,而是那些看起来不起眼的小坑。今天就把整个Spring Boot + Vue3社区养老服务平台的完整实现过程拆开聊一遍,包括为什么选这套技术栈、后端怎么设计核心接口、前端怎么处理多角色权限和可视化大屏,以及联调部署阶段遇到的那些真实问题。这不是教科书式的项目介绍,是我自己动手写完整个项目后的复盘,适合正在做毕业设计、个人项目,或者想快速了解前后端分离开发完整流程的朋友参考。
1. 项目整体设计与技术选型
1.1 养老服务平台解决的核心问题
先想清楚一个问题:社区养老服务平台到底在解决什么?不是把老人信息录进数据库那么简单。我在需求调研阶段跟社区工作人员聊过,他们最头疼的是三件事:服务工单靠纸质记录容易丢失、老人健康数据分散在多个Excel里没法统一、家属想了解老人情况只能打电话问。所以平台的核心价值应该围绕“服务可追踪、健康可记录、家属可连接”这三个目标来展开。
在功能层面,我把整个系统拆成了几个核心模块:
- 服务预约与工单管理:家属或老人线上下单,服务人员接单,管理员全程追踪工单状态
- 健康档案管理:录入老人的基础健康指标(血压、血糖、心率等),支持周期性体检数据导入
- 活动公告与报名:社区活动发布、老人或家属在线报名、签到统计
- 紧急呼叫与异常预警:一键呼叫功能,后台实时推送异常情况
- 多角色权限:老人/家属、服务人员、社区管理员三类角色的数据隔离与功能隔离
1.2 为什么选择Spring Boot + Vue3这套组合
这个选型不是拍脑袋决定的,我对比过几种方案。第一版原型其实用的是JSP + Servlet那套老技术,做完登录页面我就放弃了。原因很实际:JSP页面服务端渲染虽然简单直接,但前后端耦合严重,后期加一个页面改动就要重启整个Tomcat,而且现代前端组件库基本没法用。后来换成前后端分离架构,后端用Spring Boot,前端用Vue3,开发效率提升非常明显。
后端选Spring Boot的理由比较朴素:Java生态在中小型管理系统的业务表达上非常成熟稳定。Spring Boot的自动配置和约定优于配置设计让项目初始化成本很低,官方文档和社区资料也足够丰富。尤其像Spring Security、MyBatis-Plus这些配套组件,能让权限控制和数据库操作少写大量样板代码。对于这种带多角色权限、复杂业务状态流转的管理系统,Spring Boot的工程化优势非常明显。
前端选Vue3则是看中了它的组合式API对复杂交互逻辑的整理能力。对比Vue2的选项式API,Vue3的setup语法糖在组件复用和逻辑抽离上更灵活。比如我要在服务工单列表和健康档案两个完全不同的页面里使用同一套分页逻辑,用composables直接抽一个usePagination函数出去,两边引用就行。而且Vite的热更新速度比Webpack快很多,项目大了以后体感非常明显。
1.3 整体架构设计与数据库规划
系统的整体架构是标准的前后端分离模式:
vue3前端(Vite构建) │ 通过HTTP/JSON交互,携带JWT Token ▼ Spring Boot后端(RESTful API) │ ├── 认证模块:JWT + Spring Security ├── 业务模块:用户、服务工单、健康档案、活动、公告 └── 数据层:MyBatis-Plus + MySQL 8.0这种架构最直接的好处是前后端可以并行开发。我这边后端接口还没写完,前端同事(或者说另一个终端)已经可以用Mock数据先把页面做出来了。只要提前约定好接口返回格式{ code, data, message },联调阶段基本不会出现大冲突。
数据库设计上我比较关注表之间的关联关系。核心表包括:
user:平台登录账号,通过role字段区分老人/家属/服务人员/管理员elder_info:老人详细信息,与user是一对一关系service_order:服务工单表,核心的业务流转表health_record:健康指标记录表community_activity:活动发布表activity_signup:活动报名表
设计这些表的时候最容易犯的错误是把字段铺得太大。比如第一个版本里我为了“省事”在service_order表里直接存了服务人员的名字和电话,后来发现如果服务人员修改了手机号,历史工单里就留下旧号码。正确的做法是关联user_id,查询时再JOIN用户表。这一点建议新手特别注意,数据冗余不是不能用,但一定是在认清了业务场景之后再用。
2. 后端核心实现:Spring Boot
2.1 后端工程结构与初始化
项目初始化我推荐用Spring Initializr,无论是Idea内置的还是网页版都可以。需要注意选择的依赖版本要和自己本机的JDK版本匹配。我这里用的环境是JDK 17 + Spring Boot 2.7.8,这个组合相对稳定。选完依赖后,生成的工程结构大概是:
src/main/java/com/example/eldercare/ ├── config/ // 配置类 ├── controller/ // RESTful API入口 ├── service/ // 业务逻辑层 ├── mapper/ // 数据访问层 ├── entity/ // 数据库实体 ├── dto/ // 数据传输对象 ├── common/ // 通用类:返回结果、异常处理等 └── utils/ // 工具类包结构的划分对应了清晰的分层职责。Controller层只做参数的接收和校验,不写业务逻辑;Service层专注业务规则,比如创建工单时要联动检查用户余额、服务时间是否冲突等;Mapper层是最底层的SQL操作。建议在项目初期就坚持这个规范,不然后期维护会让你崩溃。
我在pom.xml里主要引入了这些关键依赖:
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-security</artifactId> </dependency> <dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-boot-starter</artifactId> <version>3.5.3</version> </dependency> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt-api</artifactId> <version>0.11.5</version> </dependency> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <scope>runtime</scope> </dependency>MyBatis-Plus在这里分担了很大工作量。单表CRUD基本不需要写SQL,BaseMapper里的selectById、selectPage已经够用。复杂一点的多表关联查询才需要自己写XML或注解SQL。
2.2 用户认证与权限控制
用户认证这块我走了两条路线:开始想用Spring Security里的JWT过滤器自己写,后来发现配置链路太长,就换成了拦截器方案。虽然在Spring Boot项目里面自己写拦截器确实不如Spring Security严谨,但对这种规模的项目来说,简单的JWT校验已经能满足需求。
具体实现是在config包下定义一个JwtInterceptor,在addInterceptors里注册,同时配置不需要拦截的路径白名单(登录、注册、验证码等)。每次请求进来会先检查Header里的Authorization字段,取到等号前的“Bearer ”前缀,解析出用户ID和角色,然后放在ThreadLocal或RequestAttributes中供后续代码使用。
@Component public class JwtInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { String authHeader = request.getHeader("Authorization"); if (StringUtils.isBlank(authHeader) || !authHeader.startsWith("Bearer ")) { throw new BusinessException(401, "未登录或登录已过期"); } String token = authHeader.substring(7); Claims claims = JwtUtil.parseToken(token); if (claims == null) { throw new BusinessException(401, "登录状态无效,请重新登录"); } request.setAttribute("userId", claims.get("userId")); request.setAttribute("role", claims.get("role")); return true; } }这里有两个地方容易踩坑。第一个是JWT密钥长度,jjwt要求签名密钥不能太短,我一开始用了一个很短的字符串一直报WeakKeyException,后来改成256位以上的随机字符串才正常。第二个是过期时间,按需求可以设为24小时或7天,但一定记得在新增角色权限控制时读取Token里的过期时间,否则后台改完权限要等很久才能生效。
2.3 养老服务的核心业务:服务工单闭环
服务工单是整个平台业务的中枢,从创建到完整体现了业务闭环。我把工单状态设计成了五态:待接单 → 已接单 → 服务中 → 已完成 → 已取消。状态流转不是随便改字段值,每个状态变更都要满足前置条件,比如“已完成”必须有服务人员和老人的签到记录,“已取消”必须在服务开始前才有权限操作。
@Service public class ServiceOrderService { @Transactional(rollbackFor = Exception.class) public Long createOrder(ServiceOrderCreateDTO dto, Long userId) { ServiceOrder order = new ServiceOrder(); order.setElderId(dto.getElderId()); order.setServiceType(dto.getServiceType()); order.setAppointmentTime(dto.getAppointmentTime()); order.setAddress(dto.getAddress()); order.setStatus(OrderStatusEnum.PENDING_ACCEPT.getCode()); order.setCreateBy(userId); // 校验同时间段内是否存在已接受的冲突工单 long conflictCount = orderMapper.checkConflict(dto.getElderId(), dto.getAppointmentTime()); if (conflictCount > 0) { throw new BusinessException(400, "该时间段已存在服务工单,请选择其他时间"); } orderMapper.insert(order); return order.getId(); } }创建工单时有一个业务细节:需要校验同一老人同一时间段是否已有未完成的工单。这个校验如果用代码逐条比对可能丢事务,我给service_order表加了一个组合索引(elder_id, appointment_time),查询时直接用索引过滤。这套设计上线后,基本没有出现过重复服务的投诉。
状态更新建议用状态机的方式,为了简单我用了一个Map<OrderStatusEnum, List<OrderStatusEnum>>来定义合法流转路径。后续如果要增加“重新派单”或“超时自动取消”的状态,只需要集中修改这张映射表,不用到处找判断逻辑。
2.4 健康档案与数据预警
健康档案模块本身不复杂,就是老人的身高、体重、血压等指标的增删改查。难点在于这些数据的分析展示,需要支持趋势图。后端接口出数据的时候,我不建议让前端自己算周平均值,直接在SQL层用聚合函数把一周或一个月的趋势值算好返回,前端只负责渲染。
@Select("SELECT DATE_FORMAT(record_date, '%Y-%m-%d') AS recordDate, " + "AVG(systolic_pressure) AS avgSystolic, " + "AVG(diastolic_pressure) AS avgDiastolic " + "FROM health_record WHERE elder_id = #{elderId} " + "AND record_date >= #{startDate} GROUP BY DATE_FORMAT(record_date, '%Y-%m-%d') " + "ORDER BY record_date ASC") List<HealthTrendVO> selectWeekTrend(@Param("elderId") Long elderId, @Param("startDate") String startDate);做健康预警时还有一个细节容易被忽略:连续性判断。比如老人今天血压偏高不一定是异常,但如果连续三天都偏高,就要触发预警通知家属。这部分判断我放在了定时任务里,每晚会跑一个@Scheduled方法扫描最近三天的健康数据,如果发现异常就新增预警记录并推送通知。定时任务在这个项目中承担了类似后台“哨兵”的角色。
3. 前端核心实现:Vue3 + Vite
3.1 使用Vite创建Vue3项目与目录规划
创建前端项目我直接用了Vite。对比Webpack,Vite的启动速度真的快太多了。
npm create vite@latest elder-care-frontend -- --template vue cd elder-care-frontend npm install装完后需要自己补齐项目运行时依赖。我用到了vue-router(路由)、pinia(状态管理)、element-plus(UI组件库)、axios(网络请求)、echarts(可视化图表)。
目录结构也做了模块化划分:
src/ ├── api/ // 接口请求封装 ├── assets/ // 静态资源 ├── components/ // 公共组件 ├── composables/ // 组合式函数 ├── layout/ // 页面框架 ├── router/ // 路由配置 ├── store/ // Pinia状态 ├── utils/ // 工具函数 ├── views/ // 页面组件 │ ├── dashboard/ // 可视化大屏 │ ├── order/ // 工单管理 │ ├── health/ // 健康档案 │ └── system/ // 系统管理 └── App.vue在项目初始化时有一个特别容易踩的坑:npm install之后直接跑npm run dev,有时候会提示vite版本和@vitejs/plugin-vue版本不匹配,导致页面启动空白。这类问题大概率是Node版本过旧。如果遇到了,建议先升级到Node 16.18以上,然后删除node_modules和package-lock.json重新安装。
3.2 登录态管理与Axios拦截器
前端的登录态管理是整个权限控制体系的前端配合部分。用户登录后,后端会返回JWT Token和用户基本信息,我把Token存到Pinia的持久化存储里,并统一封装Axios请求。
// src/utils/request.js import axios from 'axios' import { useUserStore } from '../store/user' import { ElMessage } from 'element-plus' const request = axios.create({ baseURL: import.meta.env.VITE_API_BASE_URL || '/api', timeout: 15000 }) 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) { if (res.code === 401) { useUserStore().logout() router.push('/login') } ElMessage.error(res.message || '请求失败') return Promise.reject(new Error(res.message)) } return res.data }, error => { ElMessage.error('网络异常,请稍后重试') return Promise.reject(error) } )这里特别强调baseURL的配置。开发时我习惯用/api前缀,配合Vite的代理配置把请求转发到后端。这样线上部署时只需要改Nginx的代理,不用动业务代码。
3.3 多角色动态路由与路由守卫
这个平台有三类核心角色,不同角色看到的菜单和页面完全不同。管理员看工单管理、用户管理、活动管理;服务人员看自己的接单列表和个人中心;老人/家属看服务预约、健康档案和活动报名。如果全部都靠v-if控制组件渲染,代码会非常凌乱。我的方案是让后端在登录接口中返回用户角色和可访问的路由标识,前端根据路由标识动态注册路由。
// src/router/index.js const constantRoutes = [ { path: '/login', component: () => import('../views/login/index.vue') }, { path: '/', component: () => import('../layout/index.vue'), redirect: '/dashboard', children: [] } ] const asyncRouteMap = { admin: [ { path: '/order', name: 'OrderList', component: () => import('../views/order/list.vue') }, { path: '/user', name: 'UserList', component: () => import('../views/user/list.vue') } ], worker: [ { path: '/order/my', name: 'MyOrders', component: () => import('../views/order/my.vue') } ], family: [ { path: '/service', name: 'ServiceReserve', component: () => import('../views/service/reserve.vue') }, { path: '/health', name: 'HealthRecord', component: () => import('../views/health/index.vue') } ] }在路由守卫里判断当前用户角色,首次进入系统时动态添加路由。这样不仅能实现菜单的按角色展示,还能在用户通过URL直接访问无权限页面时自动拦截跳转。
3.4 可视化大屏与数据看板
平台首页我设计了一个可视化大屏,展示今日服务订单量、服务完成率、健康异常预警数、月度服务趋势等核心指标。这一块用ECharts实现是最常见的方案,但中间有一些细节值得注意。
大屏页面需要自适应屏幕尺寸,组件用resize事件监听变化。
<script setup> import * as echarts from 'echarts' import { ref, onMounted, onBeforeUnmount } from 'vue' const chartRef = ref(null) let chartInstance = null const renderChart = () => { if (!chartRef.value) return chartInstance = echarts.init(chartRef.value) chartInstance.setOption({ // 服务趋势、分类占比等配置 }) } const handleResize = () => { chartInstance && chartInstance.resize() } onMounted(() => { renderChart() window.addEventListener('resize', handleResize) }) onBeforeUnmount(() => { window.removeEventListener('resize', handleResize) chartInstance && chartInstance.dispose() }) </script>看着很简单,但有几个容易踩的坑。ECharts数据异步加载时setOption会重复合并,建议在拿到接口返回数据后再创建实例;图表容器如果初始隐藏(Vue的v-show为false),绘图时容器宽度是0,图表会挤成一条竖线。我的经验是先把容器显示出来,或者用nextTick确保DOM渲染完毕再初始化。
4. 前后端联调与打包部署
4.1 跨域问题与开发环境代理
前后端联调时必遇到的一个问题:前端跑在5173端口,后端跑在8080端口,直接请求会被CORS拦截。我解决这个问题没有选择在后端加@CrossOrigin注解,而是在前端Vite配置了代理。
// vite.config.js export default defineConfig({ plugins: [vue()], server: { port: 5173, proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true, rewrite: (path) => path.replace(/^\/api/, '') } } } })这样前端请求/api/service/order时,实际会被转发到http://localhost:8080/service/order。最关键的是,这个代理配置不用改代码,开发和生产环境都能用,后端也不需要去处理烦人的CORS预检请求。
有一点提醒:跨域问题如果直接在后端开启CrossOrigin("*"),对生产环境是有安全风险的。我见过几个同学的项目为了省事全局开启了跨域,上线后造成接口被跨站请求调用。代理方式更安全。
4.2 打包发布:后端和前端
后端打包用Maven打包成Jar包:
mvn clean package -DskipTests java -jar target/eldercare-server.jar前端打包需要先配置环境变量。我在项目根目录建了.env.production文件:
VITE_API_BASE_URL=/prod-api然后执行npm run build,产物会生成在dist目录。部署时我用Nginx托管这个目录,并配置反向代理转发到后端服务。
server { listen 80; server_name elder.example.com; location / { root /var/www/eldercare; index index.html; try_files $uri $uri/ /index.html; } location /prod-api/ { proxy_pass http://127.0.0.1:8080/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } }这里最核心的是try_files $uri $uri/ /index.html。Vue3是单页应用,路由切换是靠前端Router控制的,服务端如果找不到路径直接返回404,刷新页面就会出现白屏。我部署的时候第一次忘了配这行,刷新详情页直接404,排查了半个小时才反应过来。
5. 常见问题与排查技巧实录
5.1 启动Spring Boot不显示端口号
这个问题在搜索热词里出现了,很多人启动Spring Boot项目只看到Spring Logo但控制台没有Tomcat started on port(s): 8080。最常见的原因是项目里同时引入了spring-boot-starter-web和某个测试框架,或者启动类放错了位置没有扫描到Controller。
排查思路:先看启动类,确认它在包的根路径,比如com.example.eldercare,然后看看application.yml是否显式配置了server.port但被其他profile覆盖。如果都没问题,就在启动类上加@ComponentScan明确指定扫描路径。还有一个容易忽略的原因:Java进程其实已经启动过了,控制台把旧日志清掉了。可以试试lsof -i:8080看端口是否被占用。
5.2 上传文件报413错误
项目里允许上传老人的体检报告或活动照片。通过Nginx部署后,上传大文件报413 Request Entity Too Large。这个是Nginx配置问题,不是后端代码Bug。需要修改Nginx的client_max_body_size参数:
location /prod-api/ { proxy_pass http://127.0.0.1:8080/; client_max_body_size 50m; }同时后端也要配合调整Spring Boot的文件大小限制。Spring Boot 2.x默认单文件最大1MB,批量文件10MB,需要在application.yml里修改:
spring: servlet: multipart: max-file-size: 20MB max-request-size: 50MB5.3 Vue3中ECharts在rem适配下缩放异常
项目用了postcss-pxtorem做移动端适配,结果发现ECharts图表里的字体、边距完全不随屏幕缩放。原因是ECharts的canvas画图是在JS里用像素值控制的,pxtorem只能转换CSS样式,管不到JavaScript的数值型配置。
解决思路有两种:一是监听窗口变化手动调用chart.resize()并重新计算配置项里的字号;二是用echarts.init的renderer参数配合devicePixelRatio,在移动端把大屏适配方案改成用scale缩放整体图表容器而不是依赖CSS单位。我采用的是第一种,虽然稍微麻烦,但效果最可控。
5.4 Vue2转Vue3需要改的注意点
如果你是从Vue2迁移过来的,最容易踩的坑集中在3个地方。
第一,生命周期。beforeDestroy改成了beforeUnmount,destroyed改成了unmounted。第二,全局API的使用方式变了。Vue2的Vue.prototype.$http和Vue.use在Vue3里要改成app.config.globalProperties和app.use。第三,过滤器filter被移除了,只能用计算属性或方法替代。我迁移的时候在v-for里用了一个时间格式化过滤器,改完编译不通过才查文档发现Vue3已经不支持了。
5.5 其他值得收藏的小经验
再补充几条实践中的经验。MyBatis-Plus的字段自动填充要配置好MetaObjectHandler,否则create_time、update_time这种字段每次插入都是空值。JWT过期时间和Redis缓存过期时间要设置成一致,不然用户明明刷新了Token却还是被登出。还有一个关于前端的问题,Vue3的v-model语法糖在组件通信时是update:modelValue事件,不是Vue2的input事件,自定义弹窗组件时很容易搞混。
写在最后的实操感受
整个项目开发下来,回到开头那句话,真正的技术瓶颈其实不多,难的是把这些技术组件组合起来真正跑通一个业务闭环。Spring Boot约定优于配置和Vue3的组合式API在开发效率上给我的体验是1+1大于2的——后端只专注暴露数据接口,前端把交互体验和界面表现做好,定位清晰很多。
我最想提醒后来者的一点是:不要沉迷于给项目加各种新框架和技术栈。社区养老服务平台的核心价值在于工单闭合的业务流、健康数据的稳定采集,以及能让社区工作人员真正愿意使用。如果你正在做类似的项目,先把“一个老人成功预约一次上门服务,服务人员准时上门,完成后家属能查到回执”这条主链路跑通,再考虑加Redis缓存、消息队列这些增强功能。工具永远是手段,把老人们的服务体验照顾好,才是所有技术投入的意义。
最后再分享一个小细节:我给所有核心接口都加了请求日志,用@Slf4j记录入参和出参,后来排查问题的时候省了太多事。开发阶段觉得多余的代码,往往是上线后救命的东西。