
我接手这个项目的时候正赶上社区服务中心打算上一套针对老年人的服务管理系统。当时最头疼的问题不是写代码而是怎么把“老龄化社区服务”这件听起来很宏观的事拆成一个个能落地的功能。你要是现在打开各种源码站搜“SpringBoot Vue 老龄化”能出来几百个压缩包但真正打开之后能跑起来、能讲清楚业务逻辑的没几个。这篇博文我就以这套“人口老龄化社区服务与管理平台”为引子完整拆解一遍从需求分析、数据库设计到前后端实现的全过程。我会重点讲清楚哪些地方是真正花时间打磨的哪些地方其实可以偷懒以及我在实际开发中踩进去又爬出来的坑。适合正在做毕业设计、或者刚接触前后端分离项目想找完整案例参考的同学。1. 项目定位与功能拆解先把“老龄化社区服务”翻译成系统功能很多人拿到这种题目容易犯一个错就是上来就建表写代码结果写着写着发现业务逻辑捋不清。实际上“老龄化社区服务”并不是一个功能而是一类业务场景的集合。1.1 社区养老场景下的真实业务痛点我去社区服务中心调研的时候工作人员给我列了一堆他们日常要做的事辖区内六十岁以上老人的基本信息要登记造册有的老人是独居的、有的是失能的、有的是经济困难的这些标签直接决定了他们能享受什么类型的服务。然后是服务记录的留存比如助餐、助洁、助医这些上门服务每一次做完都需要有记录不然到月底结算或者材料审计的时候根本说不清楚。再加上志愿者的排班管理、社区活动的报名统计靠Excel表格已经撑不住了。所以这个项目表面上是“管理系统”本质上是把社区养老服务的“人、事、时间”三条线串起来。人老人信息、家属信息、志愿者/护理员信息、管理员账号。事服务工单谁为谁提供了什么服务、活动社区组织了哪些活动、健康档案老人体检数据。时间服务预约时间、志愿者排班时间、活动开展时间、回访记录时间。1.2 从需求推导出的功能模块根据上面的业务痛点我把系统划分成下面这几个核心模块模块核心功能对应角色老人档案管理老人基本信息、标签独居/失能/低保、家属联系方式管理员服务工单管理工单创建、指派、状态流转、服务记录回填管理员、护理员志愿者管理志愿者信息、排班、服务时长统计管理员、志愿者健康档案管理体检数据、慢病随访记录、异常提醒管理员、护理员活动管理活动发布、报名、签到统计管理员、老人家属公告管理社区通知发布与查看管理员、所有用户数据统计工单完成率、服务类型占比、老人分布统计管理员系统管理用户管理、角色权限、登录日志超级管理员这里要特别说明一下传统的“增删改查”只是底座真正的业务核心在服务工单的状态流转上。一个工单从创建到完成中间可能要经过“待指派→已指派→服务中→已完成→待回访→已归档”这几个状态每个状态变更都要记录操作人和时间。这一块设计好了整个系统的骨架就稳了。1.3 角色权限设计系统设计了三类核心角色管理员、护理员/志愿者、老人家属访客端。权限设计上采用RBAC模型但不需要搞得太复杂两张表搞定用户表关联角色角色表关联菜单权限。前端通过路由守卫控制页面可见性后端通过拦截器验证接口权限。我见过很多项目在权限这块过度设计搞五六张表结果页面都没几个。对于这种社区服务类系统做到“菜单级权限控制”就已经完全够用了按钮级权限反而会拖慢开发进度。后面我在后端实现部分会详细说。2. 数据库设计实战老人档案、工单流转与体检数据的表结构剖析数据库是这个项目的重中之重因为业务模式决定了表之间的关联关系比较复杂。我建了大概二十张表这里挑几张最核心的展开讲。2.1 老人档案表elder_info的设计思路这张表是整个系统的数据基础几乎每个模块都在引用它。除了姓名、性别、身份证号、联系方式这些基础字段外我专门加了几个业务字段。CREATE TABLE elder_info ( id bigint(20) NOT NULL AUTO_INCREMENT, elder_name varchar(50) NOT NULL COMMENT 老人姓名, elder_sex tinyint(1) DEFAULT NULL COMMENT 性别 1男 0女, id_card varchar(18) DEFAULT NULL COMMENT 身份证号, birth_date date DEFAULT NULL COMMENT 出生日期, phone varchar(20) DEFAULT NULL COMMENT 联系电话, address varchar(200) DEFAULT NULL COMMENT 居住地址, living_status tinyint(1) DEFAULT 0 COMMENT 居住状态 0独居 1与配偶 2与子女 3养老机构, health_level tinyint(1) DEFAULT 0 COMMENT 健康等级 0自理 1半自理 2不能自理, poverty_flag tinyint(1) DEFAULT 0 COMMENT 是否低保 0否 1是, emergency_contact varchar(50) DEFAULT NULL COMMENT 紧急联系人, emergency_phone varchar(20) DEFAULT NULL COMMENT 紧急联系电话, status tinyint(1) DEFAULT 1 COMMENT 状态 0注销 1正常, create_time datetime DEFAULT CURRENT_TIMESTAMP, update_time datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_health_level (health_level), KEY idx_living_status (living_status) ) ENGINEInnoDB AUTO_INCREMENT1 DEFAULT CHARSETutf8mb4 COMMENT老人信息表;这里有几个设计细节值得展开为什么要单独存birth_date而不是直接用身份证号算因为身份证号里的出生日期是固定的但是实际业务中老人的出生日期可能根据档案材料调整而且单独存储birth_date在做年龄区间查询比如查80岁以上老人时可以直接用SQL算不需要先查出来再转性能上好很多。为什么要加poverty_flag这样的布尔字段而不是用标签表我最初的设计是搞一个标签表用多对多关系存老人的各种标签灵活是灵活但后面做统计报表的时候SQL写得想哭。后来折中了一下常用的、需要参与条件查询的标签直接冗余成字段不常用的描述性标签可以保留备注字段。这也是很多企业级项目的常见做法——过度范式化在真实业务中并不总是最优解。2.2 服务工单表service_order——状态机设计是灵魂服务工单表是业务链路的核心它关联了老人、护理员/志愿者、服务项目和时间信息。CREATE TABLE service_order ( id bigint(20) NOT NULL AUTO_INCREMENT, order_no varchar(32) NOT NULL COMMENT 工单编号, elder_id bigint(20) NOT NULL COMMENT 老人ID, service_type tinyint(1) NOT NULL COMMENT 服务类型 1助餐 2助洁 3助医 4助行 5精神慰藉, service_date date NOT NULL COMMENT 预约服务日期, service_time_slot tinyint(1) DEFAULT NULL COMMENT 时间段 1上午 2下午 3晚上, worker_id bigint(20) DEFAULT NULL COMMENT 护理员/志愿者ID, status tinyint(1) NOT NULL DEFAULT 0 COMMENT 状态 0待指派 1已指派 2服务中 3待回访 4已完成 5已取消, service_content varchar(500) DEFAULT NULL COMMENT 服务内容描述, feedback varchar(500) DEFAULT NULL COMMENT 服务反馈/回访记录, evaluate_score tinyint(1) DEFAULT NULL COMMENT 评分 1-5, cancel_reason varchar(200) DEFAULT NULL COMMENT 取消原因, create_by bigint(20) DEFAULT NULL COMMENT 创建人, assign_time datetime DEFAULT NULL COMMENT 指派时间, start_time datetime DEFAULT NULL COMMENT 实际开始时间, finish_time datetime DEFAULT NULL COMMENT 实际完成时间, create_time datetime DEFAULT CURRENT_TIMESTAMP, update_time datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_order_no (order_no), KEY idx_elder_id (elder_id), KEY idx_worker_id (worker_id), KEY idx_status_date (status, service_date) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT服务工单表;工单号order_no是我单独生成的格式为SyyyyMMdd4位随机数比如S202412150001。这里用唯一索引保证不重复生成逻辑可以放在后端Java代码里用Redis自增或者数据库自增都可以但千万不要不处理并发直接用时间戳同一秒创建两个单子就会撞。状态字段status是整个工单管理的灵魂后端通过状态机来控制操作的合法性。比如“已取消”的订单不能再变成“已完成”“已完成”的订单不能再指派护理员。这个校验放在Service层不放在Controller层避免不同接口重复校验。2.3 收藏一个经验关联查询时索引怎么设计这类系统查询最频繁的场景是列表页比如“查询某个护理员本月所有待服务的工单”这时候涉及worker_id和service_date两个条件的组合。我在status和service_date上建了联合索引idx_status_date是因为实际业务查询大量是这种模式按状态筛选 按日期范围排序。索引这东西一两个加对了效率提升明显但加多了反而拖慢插入速度。我建议后面做毕设或者自学的同学不要盲目给所有字段加索引先跑起来然后用EXPLAIN看关键查询的扫描行数再决定要不要加。3. 后端核心逻辑实现从JWT登录校验到工单状态流转后端我采用的是经典的分层架构Controller层接收请求Service层处理业务逻辑Mapper层操作数据库。下面挑三个最核心的点来说。3.1 JWT Token认证的踩坑记录登录认证我用的是JWTJSON Web Token配合Spring Boot拦截器实现。核心思路是用户登录成功后后端生成一个带有用户ID和角色信息的Token返回给前端前端之后的每次请求都在请求头里带上这个Token后端拦截器验证Token合法性。Component public class JwtInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { // 放行登录和注册接口 if (request.getRequestURI().contains(/login) || request.getRequestURI().contains(/register)) { return true; } // 从请求头获取token String token request.getHeader(Authorization); if (StringUtils.isEmpty(token) || !token.startsWith(Bearer )) { throw new BusinessException(401, 未登录或登录已过期); } // 解析token获取用户信息 Claims claims JwtUtil.parseToken(token.substring(7)); if (claims null) { throw new BusinessException(401, Token无效); } // 将用户ID放入请求上下文供后续业务使用 Long userId Long.valueOf(claims.get(userId).toString()); UserContext.set(userId); return true; } }这里有几个细节我一开始踩过坑第一Token有效期设置。我一开始设置为24小时结果测试时发现操作一会儿就过期了排查了半天发现是服务器时间比本地时间快了8小时。后来统一在JWT生成和解析时指定时区为Asia/Shanghai问题解决。第二因为业务里需要判断角色的地方很多我建议把角色编码也放进Token里这样在拦截器里就能完成菜单权限的判断避免了每次查询都要去数据库再查一次用户角色。第三关于拦截器的注册时机。记得把静态资源和预检请求OPTIONS放行不然前端跨域调用的时候会被自己的拦截器拦住。这个问题浪费了我一上午时间排查。3.2 服务工单状态流转的状态机设计工单状态流转是核心业务我采用了一种看起来比较“笨”但是非常清晰的做法——在Service层写状态校验方法。public void assignOrder(Long orderId, Long workerId) { ServiceOrder order getById(orderId); if (order.getStatus() ! 0) { throw new BusinessException(当前状态不允许指派); } order.setStatus(1); // 已指派 order.setWorkerId(workerId); order.setAssignTime(new Date()); updateById(order); }每一种状态变更都写成一个独立的方法方法第一行就是校验前置状态。这种写法代码会多出一些但是好处非常明显每一段状态流转逻辑都清晰可读排查问题的时候顺着方法线就能找到原因。工单状态流转的完整路径是这样的待指派0管理员创建工单后默认进入此状态。此时可以做修改或取消。已指派1管理员为工单分配护理员或志愿者。此时不可编辑基本信息只能取消指派重新分配。服务中2护理员上门开始服务在前端点击“开始服务”按钮。服务开始时间自动记录。待回访3护理员填写完服务记录后工单进入待回访状态。管理员需要对老人或家属进行电话回访确认服务满意度。已完成4回访通过工单归档进入统计口径。已取消5编制原因整个链路终止。这个状态机在数据层面可能产生脏数据的情况我在代码里做了兜底比如状态为“已完成”的工单如果误操作回退直接抛异常不修改数据库。宁可在接口层多校验一次也不要在数据库里放任脏状态蔓延。3.3 MyBatis动态SQL在条件查询中的实践MyBatis是这套系统里值得重点说一下的部分。很多人觉得MyBatis也就是写写XML映射文件但真正复杂的查询场景里它的动态SQL能救大命。比如工单列表页要支持按状态、按服务类型、按日期范围、按老人姓名模糊查询这个条件组合在运行时是动态变化的。select idselectOrderPage resultTypecom.example.entity.ServiceOrderVO SELECT so.*, ei.elder_name, ei.address AS elder_address, su.real_name AS worker_name FROM service_order so LEFT JOIN elder_info ei ON so.elder_id ei.id LEFT JOIN sys_user su ON so.worker_id su.id where if teststatus ! null AND so.status #{status} /if if testserviceType ! null AND so.service_type #{serviceType} /if if teststartDate ! null and startDate ! AND so.service_date gt; #{startDate} /if if testendDate ! null and endDate ! AND so.service_date lt; #{endDate} /if if testelderName ! null and elderName ! AND ei.elder_name LIKE CONCAT(%, #{elderName}, %) /if /where ORDER BY so.create_time DESC /select这段XML里要注意两个细节时间范围比较时数据库里的字段类型是date传进来的是字符串直接用大于等于和小于等于比较是能生效的但格式必须是yyyy-MM-dd。如果你在页面上传的是带时分秒的格式建议在后端先格式化再进Mapper。LIKE CONCAT(%, #{elderName}, %)这种方式是防SQL注入的写法不要直接用%${elderName}%${}是字符串拼接会存在注入风险。再说说分页。我用的分页插件是PageHelper在Mapper接口方法前加一行PageHelper.startPage(pageNum, pageSize)就能自动拦截SQL生成limit语句。但这里有个坑如果查询后面紧跟的不是你期望的SQL语句或者Mapper方法里包含了多个查询PageHelper会拦错。所以PageHelper.startPage要写在真正的查询Mapper调用前一行中间不能插入别的数据库操作。4. 前端Vue实现细节从路由守卫到服务工单看板的代码级拆解前端我用的是Vue 3 Element Plus Axios Pinia的组合。之前也犹豫过要不要用Vue 2毕竟很多网上的模板还是Vue 2的但考虑到新项目的长期维护性和生态趋势还是选了Vue 3。4.1 前端路由守卫和登录状态管理一个管理系统必须保证用户没登录时无法访问任何业务页面这个靠路由守卫实现。在router/index.js里配置全局前置守卫router.beforeEach((to, from, next) { const token localStorage.getItem(token) if (to.path /login) { next() } else { if (!token) { next(/login) } else { // 根据角色过滤可访问的路由 const roles JSON.parse(localStorage.getItem(roles) || []) if (to.meta.roles !to.meta.roles.some(role roles.includes(role))) { next(/403) } else { next() } } } })登录状态我用了Pinia来管理相比VuexPinia的API更简洁没有那么多模板代码。登录成功后的用户信息、角色数组、Token全部存在Pinia里同时Token单独存一份localStorage防止浏览器刷新后Pinia状态丢失。4.2 老人档案列表页分页、搜索与表单联动列表页是管理系统里最常见的页面形态。我以“老人档案列表”为例说一下Vue 3里这种页面的标准写法。页面上方是搜索条件姓名、健康等级、居住状态下面是表格数据底部分页组件。搜索、分页、重置这三个操作本质上都是改变查询参数后重新加载数据。我在代码里封装了一个queryParams响应式对象和getList方法const queryParams reactive({ pageNum: 1, pageSize: 10, elderName: , healthLevel: , livingStatus: }) const getList async () { const { data } await getElderList(queryParams) tableData.value data.records total.value data.total } const handleSearch () { queryParams.pageNum 1 // 搜索时回到第一页 getList() } const handleReset () { queryParams.elderName queryParams.healthLevel queryParams.livingStatus queryParams.pageNum 1 getList() }这里有个交互细节容易被忽略点击搜索按钮时pageNum必须重置为1否则你在第3页搜索结果只会显示第3页的数据给用户的感受就是“数据不见了”。类似的小细节还有删除数据后如果当前页只有一条数据删除完应该自动跳到上一页避免出现“当前页为0”的空白状态。4.3 服务工单看板用标签页和状态筛选提升操作效率工单管理页面我采用了标签页Tabs的布局方式顶部是“待指派 / 已指派 / 服务中 / 待回访 / 已完成 / 已取消”六个标签页每个标签页对应一个状态。这样处理的好处是逻辑直观管理员可以快速看到当前有多少单子待处理。标签页切换本质上就是修改queryParams.status然后重新查询。我在Element Plus的Tabs组件上绑定activeStatus监听tab-click事件触发查询。工单创建弹窗里有个联动逻辑选择服务类型为“助医”时需要额外显示“医院名称”和“科室”字段选择“助餐”时显示“忌口备注”。这块我用了v-if来控制动态表单字段数据结构的兼容性从数据库层面来做service_content字段统一存JSON字符串不同服务类型的扩展字段都塞进去前端解析时按需读取。5. 联调环境配置与部署不能忽视的端口、跨域和运行顺序前后端分离项目最烦的就是联调阶段各种环境配置问题层出不穷。这一节我说几个最常见的坑和对应的解决方案。5.1 跨域配置的三种方案对比前端开发服务器默认运行在8080端口后端Spring Boot运行在9090端口不同端口之间的请求会被浏览器同源策略拦截。解决跨域问题我尝试过三种方案方案一后端加全局CORS配置。写一个WebMvcConfigurer配置允许的源、请求头、请求方法。这个方法最直接推荐。方案二前端Vite配置代理。在vite.config.js里配置server.proxy把/api前缀的请求转发到http://localhost:9090。优点是生产环境中后端接口域名和前端页面域名不一致时更灵活但本地调试时如果后端限制了CORS代理方式也会失效。方案三Nginx反向代理。部署到服务器时在Nginx配置里统一转发比前端的代理方案更稳定也更接近线上环境。我的建议是本地开发用后端CORS配置部署用Nginx反向代理。这样两边各管一段互不干扰。后端CORS配置如下Configuration public class CorsConfig { Bean public CorsFilter corsFilter() { CorsConfiguration config new CorsConfiguration(); // 允许本地前端地址跨域 config.addAllowedOrigin(http://localhost:8080); config.addAllowedOrigin(http://localhost:5173); config.addAllowedHeader(*); config.addAllowedMethod(*); config.setAllowCredentials(true); UrlBasedCorsConfigurationSource source new UrlBasedCorsConfigurationSource(); source.registerCorsConfiguration(/**, config); return new CorsFilter(source); } }注意如果设置了setAllowCredentials(true)addAllowedOrigin就不能填*必须填具体域名否则浏览器依然会拦截。5.2 数据库时区问题导致的时间差8小时这个问题我在做工时统计的时候遇到过非常典型的“开发环境正常、测试环境时间不对”的案例。原因是MySQL连接串里没有设置时区而MySQL服务端和JVM的时区不一致。解决办法是在JDBC连接串里显式指定时区jdbc:mysql://localhost:3306/community_service?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/ShanghaiuseSSLfalseallowPublicKeyRetrievaltruecharacterEncodingutf8用来解决中文乱码问题useSSLfalse避免本地连接时提示SSL证书错误。如果你用的是MySQL 8.0以上版本建议加上allowPublicKeyRetrievaltrue否则连接的时候有可能报Public Key Retrieval is not allowed的错。5.3 部署时前端刷新404问题项目打成jar包部署会被一个问题困扰当你在前端路由里访问/elder/list这样的页面时刷新一次浏览器就404了。原因是Spring Boot默认没有配置路由转发Vue Router在history模式下刷新时浏览器会直接请求后端这个路径而Spring Boot不认识这个路径。解决方案是写一个路由转发规则把非/api开头的路径都转发到index.htmlController public class ViewController { GetMapping(value {/, /{path:[^\\.]*}, /**/{path:[^\\.]*}}) public String forward() { return forward:/index.html; } }这里用正则排除了带点号的路径目的是不影响静态资源js、css、图片等的正常访问。如果不加这个排除规则你的静态资源也会被转发到index.html导致加载失败。6. 项目改进方向与几个典型的“反面教材”避坑虽然这一套系统能够完整跑通业务流但做完之后回看我还是能列出一堆可以优化的地方和不建议踩的坑。这些经验比功能代码本身更值钱。6.1 不要把“体检数据”和“异常提醒”做成了摆设系统里健康档案模块包含老人的身高体重、血压、血糖、心率这些基础数据。前期我只做了数据的录入和展示后来社区工作人员反馈说“这功能没啥用我们还是要看Excel拉数据”。后来我加了一个简单的“健康趋势折线图”根据老人的历史体检数据画出血压、血糖的变化曲线再配合“数值超标标红”的规则这个模块的使用率一下就上去了。这个经历给我的启发是管理系统里如果只是把线下Excel搬到线上价值感会很弱。一定要找到那个能节省线下工作量的点哪怕只是多一个图表、多一条自动提醒体验就会完全不一样。6.2 切忌在权限设计上过度纠缠刚开始做的时候我在权限管理上花了大量时间设计了三套方案最后选了一套最复杂的基于数据库的角色-菜单-用户组模型。结果就是功能开发时间被压缩列表页粗糙测试用例也没能覆盖全。后来我反思社区服务系统的业务规模决定了用户量级就是几十个人角色就三类真的不需要那么复杂的权限体系。技术上“够用”比“看起来高级”重要得多。如果你是为了学技术可以在独立模块里把权限设计得复杂一些但如果是交付一个项目就要把精力放在核心业务链路工单流转、老人档案、服务统计上。6.3 定时任务与批量数据导入系统里有一个需求是“每周三自动回访未完成工单”这个我用了Spring Boot自带的Scheduled注解来实现。相比引入QuartzSpring的定时任务对于这种简单的周期调度已经足够了。Component public class RemindTask { Scheduled(cron 0 0 9 * * MON) public void weeklyRemind() { // 查询上周所有未完成工单 // 给负责人发送站内消息提醒 } }定时任务的开启要在启动类上加EnableScheduling注解不然不生效。这个点经常有同学踩坑代码抄进去了定时任务不执行查半天才发现漏了启动类注解。批量导入老人档案我用的是EasyExcel阿里的一个导出导入工具包相比直接用POI写代码EasyExcel的注解式写法确实省心很多。核心代码就几行定义一个实体类加ExcelProperty注解然后调用EasyExcel.read().sheet().doReadSync()就能解析Excel内容。7. 写在最后的踩坑总结与个人体会整个项目从设计到跑通前后端大概花了三周多的时间其中让我印象最深的不是某个技术难点反倒是一些看起来不起眼的细节。一个体会是关于“手写代码和调试之间的时间分配”。刚接触这种量级项目时会觉得代码才是大头但实际上调试时间往往比写代码的时间更长。尤其是前后端联调阶段一个字段名对不上、一个时间格式不一致排查起来非常消耗精力。所以在开发前把接口文档写好就非常关键。我自己用的方法是先写接口路径、请求参数、返回结果结构的文档哪怕用Markdown都行然后前后端照着文档并行开发每个接口完成后再联调这种方式比全部写完再统一对接省力得多。另一个体会是这样一套系统在业务上能真正解决社区的问题才是有价值的。很多毕设项目做完就扔了但如果你真的把系统部署到社区服务中心去用就会发现真实需求比题目描述复杂得多。比如老人的亲属会想看服务工单的完成情况和照片反馈护理员会希望手机上就能签到管理员会想要数据导出功能。这些“非核心”需求往往是真正让系统活起来的关键。如果你正准备做一个类似的项目我的建议是先把数据库表设计吃透——对“老龄化社区服务”这个领域来说老人档案、工单、服务记录这三张表是骨架业务流都在这上面跑。前端功能再花哨后端逻辑再复杂最终都是为这几张表服务的。想清楚这一点再往里面填代码思路就会顺畅得多。