我印象里这类退休人员、老年人管家志愿服务的系统需求,这两年突然变多了。前阵子刚帮本地某社区做完一个“退休人员老年人志愿者服务管理信息系统”,后端用的 Spring Boot,前端用的 Vue,两个端彻底分离,整体走前后端分离架构。很多朋友私信问我要设计思路,趁着项目刚验收完,把完整的拆解、技术选型、数据库设计、后端核心逻辑、前端关键实现和部署踩坑一次性整理出来,希望正在做同类系统的同学少走弯路。
这套系统到底解决什么问题?说白了就是两件事:把辖区的老年人服务需求管起来,把志愿者的服务过程和成果管起来。老年人可以通过社区工作人员登记求助需求,比如代买药、陪诊、理发、打扫卫生;志愿者登录系统抢单或由管理员派单,服务完成后记录时长、评价,系统自动累计志愿服务数据。类似一个“老年版滴滴加接单平台”,但业务复杂度主要在各种角色权限、状态流转和线下服务节点确认上,并不是简单的增删改查。
1. 项目从哪来:需求拆解与业务蓝图
1.1 为什么是“管理信息系统”而不是小程序或Excel
最初社区拿给我的需求其实很粗,只有一句话:想找一个东西能把老人和志愿者对上。有人提过用微信群接龙,也有人提过用在线表格,都被否掉了。微信群的问题是信息零散,服务记录无法统计,老人家属想查询满意度没有依据;在线表格又解决不了权限问题,志愿者、老人、管理员全看得到,隐私边界模糊。
所以要做一个真正的管理信息系统,核心要解决三个问题:
- 数据结构化:老人档案、志愿者档案、服务需求、服务工单、评价记录都要落库,能被筛选、统计、追溯。
- 角色权限清晰:管理员、志愿者、老年人(或家属代操作)三类角色的可见范围和生产数据能力完全不同。
- 服务流程闭环:从需求登记、审核、派单、执行、确认、评价,每一步都有据可查,避免扯皮。
这套业务模型用 Spring Boot 来落地非常合适,因为后端只需要做好稳定的接口和数据建模,复杂的业务状态流转交给后端的状态机逻辑控制,前端只负责呈现和交互,边界清晰,后续做微信小程序或者移动端也容易扩展。
1.2 核心角色与业务流程梳理
参与角色就三个:系统管理员(一般是社区工作人员)、志愿者(很多本身就是低龄退休人员,这是这个系统最有意思的地方)、老年人(实际服务对象,多数由子女或社区工作人员代操作)。这里特别讲一下“退休人员”的双重身份:刚退休的 60 岁出头、身体硬朗的人,往往是最活跃的志愿者群体;80 岁以上、行动不便的,是核心服务对象。一个退休人员自己可能是志愿者,同时家里父母又是被服务对象,实际使用时往往一个人注册两种身份。
核心业务流程可以抽象成四条线:
- 服务需求线:老人/家属 → 提交需求 → 管理员审核 → 发布 → 志愿者认领/管理员指派 → 开始服务 → 完成确认 → 双方评价。
- 志愿者管理线:报名注册 → 资质审核 → 服务领单 → 服务执行 → 时长评价累计 → 星级评定。
- 老人档案线:批量导入/单条建档 → 健康标签维护 → 服务历史查看 → 定期回访提醒。
- 综合统计线:按社区、按服务类型、按时段统计工单量、完成率、志愿者服务总时长。
1.3 功能清单边界划定
这类项目特别容易越做越胖,需求方今天想加个论坛,明天想加个直播。我在需求评审阶段就定死了范围:核心功能只有六个模块,任何超出这些范围的先记入二期。
| 功能模块 | 核心功能点 | 说明 |
|---|---|---|
| 老人档案 | 建档、维护、健康标签、家属联系人 | 支持 Excel 批量导入 |
| 志愿者档案 | 注册审核、技能标签、服务时长累计 | 技能标签用于智能派单 |
| 服务需求池 | 需求登记、审核、发布、撤销 | 状态实时可见,避免重复认领 |
| 工单管理 | 派单、认领、转单、完成、确认 | 全链路状态机控制 |
| 评价体系 | 服务评价、投诉反馈、回访记录 | 双向评价,数据驱动志愿者评级 |
| 数据统计 | 服务量、时长、覆盖率、满意度 | 面向管理者的看板功能 |
范围锁死后开发节奏快了很多,整个后端核心接口开发加联调大概用了四周,这还没算上需求反复的时间。所以做这类系统,先把功能边界签明白,比什么都重要。
2. 技术选型为什么是 Spring Boot + Vue
2.1 后端为什么不用 SSM 或更重的框架
很多人一看到“Spring Boot”就问,为什么不直接用 SSM?其实 SSM 和 Spring Boot 底层都是 Spring 框架,但 Spring Boot 的价值在于自动配置,把以往 SpringMVC 里繁琐的 XML 配置、数据源配置、事务配置全部变成约定优于配置。这个项目里有大量的 CRUD 接口,如果每个 Mapper 都要手写一大堆 XML,开发效率会非常低。
我选型时定了这样一套组合:
- Spring Boot 2.7.x:稳定版,社区资料多,出问题好排查。
- MyBatis-Plus:单表 CRUD 直接用内置方法,复杂统计写注解 SQL,少掉一半体力活。
- MySQL 8.0:业务数据量不大,但需要事务、关联查询、统计聚合,MySQL 足够。
- Redis:存验证码、JWT 黑名单、高频统计缓存,后面细讲。
- Spring Security + JWT:无状态认证,前后端分离标准方案。
选这套组合最核心的理由是:团队成员对 MyBatis 的熟悉度远高于 JPA,MyBatis-Plus 又能极大减少 SQL 编写量,同时对复杂查询有完全掌控力,遇到性能问题能直接改 SQL,排查路径短。如果是 JPA 那种自动生成 SQL 的方式,一旦关联关系复杂,SQL 失控很难调优。
2.2 前端为什么用 Vue 而不是 JSP 模板
这种管理系统如果还用 JSP 模板引擎,前后端代码会全部搅在一起,后期维护绝对是噩梦。所以坚决选择前后端分离,前端用 Vue 3 + Element Plus + Pinia + Vue Router,通过 Axios 调后端接口。构建工具直接上 Vite,开发热更新秒级响应,比 Webpack 快太多了。
前端做成了标准的中后台管理系统布局:左侧菜单栏、顶部用户区域、中间内容区,没有自己造轮子,直接用 Element Plus 的管理后台模板改的,重点关注了表格、表单、标签这些组件的定制。因为业务中要展示大量老人和志愿者的标签信息,例如高血压、糖尿病、需要陪诊、擅长理发,Element Plus 的 el-tag 组件配合可配置颜色非常顺手。
2.3 部署形态与团队协作考量
既然是社区类项目,生产环境服务器配置普遍不高,基本一台 2C4G 的云服务器就能跑。前端构建成静态资源丢 Nginx,后端打成 jar 包用 systemd 守护运行,MySQL 和 Redis 装在同一台机器上,单机部署足够支撑几千名老人加志愿者的规模。
我习惯在项目里从第一天就引入 RESTful API 规范,所有接口路径统一 /api/v1 开头,前端请求统一走 Axios 实例,统一处理 HTTP 状态码和业务码。前后端联调时用的是 Swagger 自动生成接口文档,后端写完接口一启动就能在 /doc.html 看到文档,大妈级操作难度,基本不需要额外沟通成本。
3. 数据库设计:整个系统最容易翻车的地方
3.1 核心表结构:把老人的服务档案拆干净
数据库设计是这类系统的命根子,前期表关系没想清楚,后期改表结构就是灾难。我把表拆成了五个核心块:用户与角色、老人档案、志愿者档案、服务工单、评价与统计。
先看用户体系。所有登录账号统一放到 sys_user 表,然后用 user_type 字段区分是管理员还是志愿者,老人账号单独用 senior_account 关联,避免一个老年人要记两个账号。实际使用中很多老人根本没有手机,账号多由子女注册后代操作,这块设计要足够宽容。
老人档案表 senior_info 是核心业务表,字段设计如下:
| 字段名 | 类型 | 说明 |
|---|---|---|
| id | bigint | 主键 |
| name | varchar | 老人姓名 |
| id_card | varchar | 身份证号,唯一索引 |
| gender | tinyint | 性别 |
| birth_date | date | 出生日期,用于统计年龄结构 |
| address | varchar | 家庭住址 |
| community_id | bigint | 所属社区 |
| health_tags | varchar | 健康标签,逗号分隔,例如高血压,糖尿病 |
| urgent_contact | varchar | 紧急联系人 |
| urgent_phone | varchar | 紧急联系电话 |
| status | tinyint | 档案状态:正常/注销 |
健康标签没有单独建表,直接逗号分隔存在字段里,因为标签本身是低维度枚举,建关联表反而增加查询复杂度。如果后面要做健康标签多维统计,可以改成冗余表,但现阶段简单就是美。
3.2 志愿者和服务工单怎么建模
志愿者表 volunteer_info 和老人表类似,但多了一套服务能力字段。这里的关键设计是技能标签和服务类型字段:服务类型按大分类存 code,例如 1 助餐、2 助洁、3 助医、4 陪聊、5 代办、6 陪同外出,技能标签存自由文本,比如会理发、有车、懂康复护理。接单派单时用服务类型做硬匹配,再用技能标签做软加分。
服务工单表 service_order 是整套系统的核心流程表,状态字段完全反映服务链路:
| 字段名 | 类型 | 说明 |
|---|---|---|
| id | bigint | 主键 |
| order_no | varchar | 工单号,年月日+随机数 |
| senior_id | bigint | 老人 ID |
| volunteer_id | bigint | 志愿者 ID,可空,待认领时为空 |
| service_type | tinyint | 服务类型,1-6 |
| need_content | varchar | 需求内容描述 |
| appointment_time | datetime | 预约服务时间 |
| status | tinyint | 状态:1待审核 2待认领 3已派单/已认领 4服务中 5待确认 6已完成 7已取消 |
| actual_start_time | datetime | 实际开始时间 |
| actual_end_time | datetime | 实际结束时间 |
| evaluate_score | tinyint | 老人评价分数 1-5 |
| evaluate_comment | varchar | 老人评价内容 |
| create_time | datetime | 创建时间 |
为什么把预约时间和实际时间分开?因为老人约了 9 点到,志愿者路上耽误 10 分钟,迟到属于什么性质?责任认定必须靠这两个时间字段对比,没有实际开始时间,后面做服务准时率统计就是空谈。
3.3 实体关系与冗余取舍:老项目改表的血泪教训
表关系上,最核心的关联就是 service_order 关联 senior_info 和 volunteer_info,都是多对一。我特意没有把老人姓名、电话冗余到工单表,而是关联查询时 join 出来。这一点和很多人习惯不一样,但想想就知道:老人改了一个手机号,所有历史工单如果冗余了旧号码,回访时打到旧号就搞笑了。冗余可以,但前提是冗余字段不允许被修改,或者冗余只存在于统计表中,业务主表尽量保持单一数据源。
还有一个设计教训:初期我设计了一个服务地址字段,以为老人服务地址可能和住址不一样。后来数据显示 90% 的服务发生在老人家里,直接复用 senior_info 的 address 即可。多余字段拉高了录入成本,还容易导致数据不一致。这类系统永远要记住:手机号、身份证号、地址这类高频字段,全库只准有一个可信来源。
4. 后端核心实现:从登录到服务闭环
4.1 认证与权限:JWT 不是存了个 Token 那么简单
认证方案是 Spring Security + JWT,但很多人直接 copy 网上的代码就跑,实际业务里坑不少。我用的是无状态 Token 方案:用户登录后签发 JWT,后续请求在 Authorization 头带 Bearer Token。Token 里只放 userId、userType、loginName,所有状态信息后端不存,减轻 Redis 压力。
但这套方案有一个绕不开的安全问题:Token 泄露后没法主动踢人。社区系统使用场景特殊,志愿者共用一个电脑的情况不少,所以我在 Redis 里维护了一个 token 白名单,key 是用户 ID,value 是当前有效的 Token,登录时写入,调接口时校验一致性。这么做的目的是支持管理员一键“强制下线”,直接把 Redis 里的记录删掉即可,非常实用。
对应代码如下:
@Component public class JwtAuthenticationFilter extends OncePerRequestFilter { @Autowired private StringRedisTemplate redisTemplate; @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { String token = resolveToken(request); if (token != null && JwtUtils.validateToken(token)) { Long userId = JwtUtils.getUserId(token); // 校验 Redis 中的白名单 Token 是否一致 String cached = redisTemplate.opsForValue().get("login:token:" + userId); if (token.equals(cached)) { UserPrincipal principal = UserPrincipal.builder() .userId(userId) .userType(JwtUtils.getUserType(token)) .build(); UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(principal, null, principal.getAuthorities()); SecurityContextHolder.getContext().setAuthentication(authentication); } } filterChain.doFilter(request, response); } }同时接口权限校验不能全放进框架,直接在业务上判断当前登录用户类型。比如“老人档案列表”接口,管理员能查全部,志愿者只能查自己服务过的老人,这个规则用注解是表达不清楚的,必须写一个 @CurrentUser 参数解析器,把当前登录人注入 Controller 方法,业务层自行判断。
4.2 服务工单闭环:状态机的设计和实现
工单状态流转是整个系统最核心的业务逻辑。我画状态图的时候脑子里非常清晰:待审核 → 待认领 → 已认领/已派单 → 服务中 → 待确认 → 已完成,中间任何环节允许管理员取消。每一跳都必须校验前置状态,否则脏数据满天飞。
项目里我用了一个最简单实用的方案:状态枚举类加状态流转校验方法,没有一个重型的 Workflow 引擎。
public enum OrderStatusEnum { PENDING_AUDIT(1, "待审核"), PENDING_CLAIM(2, "待认领"), ASSIGNED(3, "已派单"), SERVING(4, "服务中"), PENDING_CONFIRM(5, "待确认"), COMPLETED(6, "已完成"), CANCELED(7, "已取消"); private final Integer code; private final String desc; public static boolean canTransfer(Integer from, Integer target) { // 用二维矩阵或者 set 判断状态是否可流转 return TRANSFER_MAP.getOrDefault(from, Collections.emptySet()).contains(target); } }具体实现时,所有工单状态变更都封闭在 ServiceOrderService 内部,提供 submitAudit、publish、claim、assign、startService、completeService、confirmService、cancel 八种操作方法,每个方法内部第一步都是校验状态允许性,第二步做业务检查(志愿者当天接单数不能超过上限),第三步更新数据库并写入状态变更流水表。
状态变更流水表 order_status_log 非常重要,每转一次状态记一条记录,谁在什么时间把工单从待审核改成了待认领,全部有据可查。这个表看似不起眼,但社区服务一旦发生纠纷,它就是唯一的电子证据链。
4.3 高频接口的并发与性能:防超卖才是技术重点
服务订单最大的技术风险不是查询慢,而是多人同时认领同一个工单时产生超卖。志愿者端点“认领”按钮的一瞬间,如果两个人都提交了请求,数据库不加控制就会产生两笔绑定记录。
解决方案就是数据库乐观锁:在 service_order 表加 version 字段,认领时 UPDATE 操作带上 WHERE version = 上一版。如果更新影响行数为 0,说明别人已经抢先认领,直接提示“手慢了,下次再来”。
UPDATE service_order SET volunteer_id = #{volunteerId}, status = 3, version = version + 1 WHERE id = #{orderId} AND status = 2 AND version = #{version}这里还要提一个“认领冷却时间”的设计。需求评审时社区工作人员告诉我,会有极少数志愿者看到单就抢,抢完又取消,导致真正想做的人接不到单。所以我加了一个规则:志愿者取消已认领工单后,24 小时内不能认领同一老人的新需求,相当于一个软惩罚机制,上线后取消率下降了很多。
另外所有列表页都做了分页,MyBatis-Plus 的分页插件一配就行。需要特别注意的是统计接口,例如“某服务类型的月服务总量”,直接用 GROUP BY 和 DATE_FORMAT 做聚合,定时任务在凌晨计算完写入 statistics_daily 表,前端展示直接查统计表,不要实时跑聚合 SQL,否则月底数据量上来后,页面会卡到怀疑人生。
5. 前端 Vue 实现要点:从能用到好用
5.1 路由权限与管理员、志愿者、老人三套视图
前端权限控制是我觉得 Vue 项目里最容易被做乱的地方。菜单数据显示是根据角色动态生成的,后端接口返回菜单树,前端动态添加路由。这么做的好处是:新增一个功能时,后端菜单表加一条记录,前端不用跟着发版,对这类长期维护的系统非常友好。
具体实现中我用的方案是路由守卫:每次跳转前,从 Pinia 里获取用户角色和菜单权限,如果没有权限直接跳 403 页面,同时拦截所有 Axios 响应,后端返回 401 时自动跳到登录页。
由于用户类型有三类,页面逻辑差异较大:管理员看到全部菜单;志愿者看到“服务大厅、我的工单、我的评价”;老人端实际很少做,大部分老人的操作由社区工作人员代为完成,所以我没有给老人复杂的前端页面,只做了一个“我的诉求”查看页,避免老年用户面对复杂界面产生恐慌感,这个决策是要点。
5.2 列表页增强:筛选联动与标签渲染
列表页是管理系统的门面,也是使用频率最高的页面。老人档案列表页和工单列表页我都重点做了筛选区设计:按社区、按服务类型、按状态、按时间范围组合筛选。筛选条件变化后,前端重新请求接口并重置分页页码到第 1 页,这个小细节很多初级开发会忽略,导致从第 5 页筛选后看到的还是第 5 页的数据,非常影响体验。
标签渲染统一封装了 ServiceTag 组件,接收服务类型编码,渲染对应颜色和文字。比如助餐是橙色、助医是红色、陪聊是蓝色。这不仅仅是好看,真实使用中药房配色能让工作人员一眼看出今日服务重点。
志愿者服务排行榜也是需求中的高频功能,做了一个简单的 Top10 列表,后台查询时按 SUM(服务时长) 倒序,前端用进度条展示相对第一名完成度,数据刷新用定时器每 30 秒轮询一次,实现方式便宜又有效。
5.3 适老化和移动端适配:字要大,按钮要粗
社区工作人员经常拿手机在现场操作,尤其去老人家入户建档的时候,所以前端页面必须兼顾 PC 和手机。我用的是响应式栅格布局,关键表单在窄屏时变单列。字体大小整体 14px 起步,适老化页面用到 18px,按钮高度做成 40px 起步,避免手指点不到。
最烦人的是日期和时间组件的移动端体验,Element Plus 的日期选择器在手机上操作很难受。后来我把“预约服务时间”拆成两个下拉框:选择日期、选择时段(上午/下午/晚上),虽然后端存的还是 datetime,但这种交互大大降低了误操作率,社区工作人员反馈非常好,这个改动强烈建议同类项目直接抄。
6. 联调、部署与经典问题排查实录
6.1 开发环境里的跨域和会话问题
前后端分离开发时,端口不同必然面对跨域问题。最省事的方案是后端配置 CORS 允许所有来源,但上线前一定要收紧。我在开发阶段用 Nginx 把前端请求代理到后端,避免后端的跨域配置带到生产环境。
我的做法:前端 Vite 配置 proxy 选项,把 /api 开头的请求代理到 localhost:8080,这样浏览器看到的是同源请求,后端完全不需要开启 CORS,生产环境同样由 Nginx 反向代理,前后端代码都不用改。
另外一个特别容易踩的坑是“会话失效”排查。因为救急登录过的人很多,有人反馈登录后过一会儿就被踢下线。排查日志发现是两名用户 ID 相同导致 Redis 白名单互相覆盖,原因就是数据库里 sys_user.id 和 senior_id 混用,后来统一规范了 ID 生成策略:所有主键用 MyBatis-Plus 的 ASSIGN_ID 雪花算法,彻底杜绝撞号。
6.2 生产部署的具体方案
生产环境我用了最简单的单机部署方案:
- 前端构建:npm run build,生成 dist 静态目录,配置到 Nginx。
- 后端构建:mvn clean package,生成可执行 jar,用 systemd 守护进程运行。
- 数据库:MySQL 8.0 定时凌晨 3 点全量备份,保留最近 14 天。
Nginx 配置核心如下:
server { listen 80; server_name your-domain; # 前端静态资源 root /opt/web/dist; index index.html; # 解决 Vue Router history 模式刷新 404 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; } }这套配置踩过一个文件上传的坑:头像上传接口超时。因为 Nginx 默认请求体大小限制是 1M,服务照片一传就报 413。后来在 server 块里加了 client_max_body_size 20m 解决,这个配置几乎所有管理系统都会遇到。
6.3 高频问题的排查速查表
我把这个项目里遇到的高频问题整理成一张速查表,很多问题是运行了半年之后才暴露的,非常值得记录。
| 症状 | 根因 | 解决办法 |
|---|---|---|
| 登录后随机被踢下线 | Redis Token 白名单被覆盖 | 检查 ID 生成策略,采用雪花算法保证唯一 |
| 上传图片返回 413 | Nginx 上传大小限制 | 配置 client_max_body_size 20m |
| 前端刷新页面 404 | Vue Router history 模式未配置 | 配置 try_files $uri $uri/ /index.html |
| 接口返回 Timezone 错乱 | 数据库时区和 JVM 时区不一致 | 连接串加 serverTimezone=Asia/Shanghai |
| 统计页面加载很慢 | 实时聚合 SQL 未走缓存 | 定时任务预统计,写入 statistics_daily 表 |
| 前端显示的时间差 8 小时 | 后端序列化用了默认时区 | Jackson 配置时区为 GMT+8 |
| 微信内置浏览器页面缩放 | 未设置响应式 viewport | 添加 viewport meta 并设置 initial-scale=1 |
还有一个细节:后端返回给前端的时间格式统一为字符串 yyyy-MM-dd HH:mm:ss,不要返回时间戳。因为前端处理时区非常容易错,8 小时的偏移在服务时长统计中是致命的。
7. 二期的可能性:这套系统的扩展方向
项目验收后,社区运营方非常满意。不过根据我的经验,这类系统的价值远不止现在的功能。如果后续要做二期,有几个方向值得考虑:
第一,智能派单。现在是根据服务类型硬匹配,遇到同一时间多个需求时靠人工派。可以做成“志愿者评分加权推荐”,把服务类型匹配、技能标签匹配、历史评价、距离远近综合打分,按分排序推荐给管理员,能节省大量派单时间。
第二,服务轨迹和求助定位。如果接入小程序,志愿者到达老人住址时可以用 GPS 打卡,老人遇到突发状况可以一键 SOS,这个需求在很多老龄社区呼声很高。
第三,对接智能硬件。很多老年人家里有血压计、血糖仪、紧急呼叫器,如果能把这些设备的数据对接进来,在老人档案里实时展示健康数据,志愿者上门服务前就能掌握基础情况,服务精准度完全不一样。
第四,服务银行概念。把志愿者的服务时长以“时间币”形式沉淀,未来自己或家属需要服务时可以兑换消耗,这是很多城市正在试点的新模式,系统底层的数据结构其实已经支持了。
这些方向不是空话,都是基于半年多运营过程中社区方主动提出来的诉求,二代目开发心里是有底稿的。
做这个项目最大的体会是:技术栈本身没有任何门槛,Spring Boot 和 Vue 都是烂大街的熟练工,难点永远在理解业务。你得蹲在社区服务中心看他们怎么接电话、怎么派活、怎么跟老人沟通,才能设计出一套真正能落地、而不是网上烂大街的“通用后台管理系统”。志愿者的荣誉感、老人的信任感,这些微妙的东西用代码很难完美表达,但至少能把线下服务的每一环稳稳记录下来,这就是这类信息系统的最大功德。